diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17292684a..708af5f44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: build_api: needs: test_change_limit - if: ${{ always() && !failure() && !cancelled() }} + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest container: image: python:3.12.2 @@ -80,13 +80,14 @@ jobs: webapp: needs: test_change_limit - if: ${{ always() && !failure() && !cancelled() }} + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest container: - image: node:19.8.1 + image: node:22 options: --user root env: CI: true + TZ: America/Denver steps: - name: Checkout ${{ github.event.repository.name }} uses: actions/checkout@v4 @@ -96,9 +97,7 @@ jobs: run: | # Navigate to the webapp directory and perform necessary setup cd $GITHUB_WORKSPACE/webapp - npm install -g nodemon - npm init -y - npm install --force + npm ci # Run tests with coverage and suppress failures npm test -- --coverage @@ -116,7 +115,7 @@ jobs: build_keycloak_provider: needs: test_change_limit - if: ${{ always() && !failure() && !cancelled() }} + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest container: image: maven:3.9.9-eclipse-temurin-21 @@ -142,17 +141,54 @@ jobs: name: keycloak-user-provider-test-results path: $GITHUB_WORKSPACE/resources/keycloak/custom-user-provider/target/surefire-reports + build_rsu_info_bridge: + needs: test_change_limit + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "25" + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-rsu-info-bridge-${{ hashFiles('**/services/rsu-info-bridge/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-rsu-info-bridge- + ${{ runner.os }}-maven- + + - name: Build and Test with Maven + run: | + cd $GITHUB_WORKSPACE/services/rsu-info-bridge + ./mvnw clean verify + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + with: + name: rsu-info-bridge-test-results + path: $GITHUB_WORKSPACE/services/rsu-info-bridge/target/surefire-reports + build_intersection_api: needs: test_change_limit - if: ${{ always() && !failure() && !cancelled() }} + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest - container: - image: maven:3.9.9-eclipse-temurin-22 - options: --user root steps: - name: Checkout Repository uses: actions/checkout@v4 + - name: Set up JDK 22 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "22" + - name: Cache Maven packages uses: actions/cache@v4 with: @@ -190,6 +226,92 @@ jobs: run: | rm -rf $GITHUB_WORKSPACE/services/intersection-api/api/target + build_flyway_image: + needs: [test_change_limit, validate_migrations] + if: ${{ !cancelled() && needs.validate_migrations.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/cvmanager-flyway + tags: | + type=sha + type=ref,event=branch + + - name: Build and push Flyway image + uses: docker/build-push-action@v6 + with: + context: resources/db/ + file: resources/db/Dockerfile + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + validate_migrations: + needs: test_change_limit + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + services: + postgres: + image: postgis/postgis:15-3.4 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout ${{ github.event.repository.name }} + uses: actions/checkout@v4 + + - name: Run Flyway migrations + run: | + docker run --rm \ + --network host \ + -v "$GITHUB_WORKSPACE/resources/db/migration:/flyway/sql" \ + -v "$GITHUB_WORKSPACE/resources/db/flyway.toml:/flyway/conf/flyway.toml" \ + flyway/flyway:10-alpine \ + -url=jdbc:postgresql://localhost:5432/postgres \ + -user=postgres \ + -password=postgres \ + migrate + + - name: Verify migration state + run: | + docker run --rm \ + --network host \ + -v "$GITHUB_WORKSPACE/resources/db/migration:/flyway/sql" \ + -v "$GITHUB_WORKSPACE/resources/db/flyway.toml:/flyway/conf/flyway.toml" \ + flyway/flyway:10-alpine \ + -url=jdbc:postgresql://localhost:5432/postgres \ + -user=postgres \ + -password=postgres \ + info + sonar: if: github.event.pull_request.base.repo.owner.login == 'usdot-jpo-ode' needs: [build_api, webapp] diff --git a/.gitignore b/.gitignore index cdf645256..2e034c9df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ htmlcov .pytest_cache local_blob_storage services/coverage.xml +.idea/** +**/*.iml diff --git a/.gitmodules b/.gitmodules index fb7f2dfd7..0d68b33bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "jpo-utils"] path = jpo-utils - url = https://github.com/usdot-jpo-ode/jpo-utils + url = https://github.com/CDOT-CV/jpo-utils branch = master diff --git a/.vscode/launch.json b/.vscode/launch.json index 8ff266510..04e3d081d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,7 +35,23 @@ "stopOnEntry": false, "program": "${workspaceRoot}/services/api/src/main.py", "env": { - "FLASK_APP": "${workspaceRoot}/services/api/src/main.py" + "PYTHONPATH": "${workspaceRoot}/services", + "FLASK_APP": "${workspaceRoot}/services/api/src/main.py", + "FLASK_RUN_PORT": "8081", + "PG_DB_HOST": "localhost:5432", + "PG_DB_USER": "postgres", + "PG_DB_PASS": "postgres", + "PG_DB_NAME": "postgres", + "MONGO_DB_URI": "mongodb://ode:replace-me@localhost:27017/?directConnection=true&authSource=admin", + "MONGO_DB_NAME": "CV", + "MONGO_PROCESSED_BSM_COLLECTION_NAME": "processed_bsm", + "MONGO_PROCESSED_PSM_COLLECTION_NAME": "processed_psm", + "ENABLE_WZDX_FEATURES": "false", + "CORS_DOMAIN": "*", + "KEYCLOAK_ENDPOINT": "http://localhost:8084", + "KEYCLOAK_REALM": "cvmanager", + "KEYCLOAK_API_CLIENT_ID": "cvmanager-api", + "KEYCLOAK_API_CLIENT_SECRET_KEY": "keycloak-secret-key" }, "args": ["run"], "envFile": "${workspaceRoot}/services/api/.env", @@ -48,13 +64,26 @@ "mainClass": "us.dot.its.jpo.ode.api.ConflictApiApplication", "projectName": "intersection-api", "envFile": "${workspaceFolder}/.env", - "preLaunchTask": "run-docker-compose-intersection-no-api" + "preLaunchTask": "run-intersection-no-api", + "env": { + "AUTH_SERVER_URL": "http://localhost:8084", + "KAFKA_BROKER_IP": "localhost", + "DB_HOST_IP": "localhost", + "PG_DB_PASS": "postgres", + "POSTGRES_SERVER_URL": "jdbc:postgresql://localhost:5432", + "CM_SERVER_URL": "http://localhost:8082", + "UNSUBSCRIBE_SECRET_KEY": "b5f9070ca58d4576ea6a2d8d4c1b97caf8abc2554198f8f9acf86d0d31a52a52", + "INTERSECTION_SMTP_USERNAME": "admin", + "INTERSECTION_SMTP_PASSWORD": "password", + "INTERSECTION_SMTP_AUTH": "true", + "INTERSECTION_SMTP_STARTTLS": "false" + } } ], "compounds": [ { "name": "Debug Solution", - "configurations": ["Python: Flask", "Launch web app with Intersection Data"] + "configurations": ["Python: Flask", "Launch web app with Intersection API"] } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 524d22b21..9f4a6c301 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,9 @@ "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, + "[html]": { + "editor.formatOnSave": false + }, "cSpell.words": [ "assertj", "authpriv", @@ -52,6 +55,7 @@ "dont", "drivername", "Dsrc", + "dtos", "formik", "fromaddr", "Fwding", @@ -78,8 +82,6 @@ "MESSAGETYPE", "millis", "mongosh", - "moove", - "mooveai", "msgfwd", "multidict", "Multivalued", @@ -88,6 +90,7 @@ "OIDC", "OIDCID", "pgdb", + "pgquery", "PGSQL", "postgis", "pythjon", @@ -106,8 +109,8 @@ "rwdata", "rxtxfwd", "SASL", - "secretmanager", "Scms", + "secretmanager", "SNMP", "snmpcredential", "snmperrorcheck", @@ -120,13 +123,13 @@ "subquery", "svcs", "TABLENAME", + "Thymeleaf", "timefield", "toaddrs", "txrxmsg", "upgrader", "usdot", "usdotjpoode", - "userauth", "utctimestamp", "writedb", "WSMP", @@ -153,5 +156,6 @@ "json", "jsonc", "markdown" - ] + ], + "java.debug.settings.onBuildFailureProceed": true } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4096226de..561fb8e3b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -15,44 +15,45 @@ "command": "${command:python.interpreterPath} -m pytest -v --cov-report xml:cov.xml --cov ." }, { - "label": "run-intersection", + "label": "run-keycloak-and-postgres", "type": "docker-compose", "dockerCompose": { "up": { "detached": true, "build": true, - "profiles": ["cvmanager_postgres", "cvmanager_keycloak", "intersection"] + "profiles": ["cvmanager_postgres", "cvmanager_keycloak"] }, "files": ["${workspaceFolder}/docker-compose.yml"], "envFile": "${workspaceFolder}/.env" - }, - "dependsOn": ["run-keycloak-and-postgres"] + } }, { - "label": "run-docker-compose-intersection-no-api", + "label": "run-intersection", "type": "docker-compose", "dockerCompose": { "up": { "detached": true, "build": true, - "profiles": ["intersection_no_api", "cvmanager_api", "mongo_full", "kafka_full", "kafka_connect_standalone"] + "profiles": ["intersection", "mongo_full"] }, - "files": ["${workspaceFolder}/docker-compose.yml", "${workspaceFolder}/docker-compose-intersection.yml"], + "files": ["${workspaceFolder}/docker-compose.yml"], "envFile": "${workspaceFolder}/.env" - } + }, + "dependsOn": ["run-keycloak-and-postgres"] }, { - "label": "run-keycloak-postgres-mongodb-kafka", + "label": "run-intersection-no-api", "type": "docker-compose", "dockerCompose": { "up": { "detached": true, "build": true, - "services": ["cvmanager_keycloak", "cvmanager_postgres"] + "profiles": ["mongo_full"] }, "files": ["${workspaceFolder}/docker-compose.yml"], "envFile": "${workspaceFolder}/.env" - } + }, + "dependsOn": ["run-keycloak-and-postgres"] }, { "label": "run-full-conflictmonitor", @@ -61,24 +62,12 @@ "up": { "detached": true, "build": true, - "services": [ - "cvmanager_postgres", - "cvmanager_keycloak", - "kafka", - "kafka_init", - "ode", - "geojsonconverter", - "conflictmonitor", - "deduplicator", - "conflictvisualizer_api", - "mongodb_container", - "connect" - ] + "services": ["kafka_full", "kafka_connect_standalone", "conflictmonitor"] }, - "files": ["${workspaceFolder}/docker-compose-full-cm.yml"], + "files": ["${workspaceFolder}/docker-compose.yml"], "envFile": "${workspaceFolder}/.env" }, - "dependsOn": ["run-keycloak-and-postgres"] + "dependsOn": ["run-intersection"] } ] } diff --git a/README.md b/README.md index 6e881e0ab..18d53da5a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ The JPO Connected Vehicle Manager is a web-based application that helps an organization manage their deployed CV devices (Roadside Units and Onboard Units) through an interactive, graphical user interface using Mapbox. -**GUI:** ReactJS with Redux Toolkit and Mapbox GL - -**API:** Python 3.12.2 - **Features:** - Visualize devices on a Mapbox map @@ -28,10 +24,70 @@ The JPO Connected Vehicle Manager is a web-based application that helps an organ To provide feedback, we recommend that you create an "issue" in this repository (). You will need a GitHub account to create an issue. If you don’t have an account, a dialog will be presented to you to create one at no cost. +## Quick Start + +This section is brief - for more detailed instructions, please see [Getting Started](#getting-started) + +### Requirements + +- Docker and Docker Compose installed on your machine + +### Steps + +1. Copy the `sample.env` file to a new file named `.env` in the root directory of the project. +2. Edit the `.env` file to set the required environment variables. At a minimum, you will need to set the following variables: + - `DOCKER_HOST_IP`: The IP address of your Docker host. This can be found through linux/wsl through the command "ifconfig", or "localhost" if using Docker Desktop on Windows or Linux (not mac). + - `MAPBOX_TOKEN`: Any valid mapbox token. Please see [Creating a Mapbox Token](#creating-a-mapbox-token) for instructions on how to create and account/generate a new token + - `MAVEN_GITHUB_TOKEN`: A GitHub access token used to access public GitHub Maven packages. See [Github Token](#github-token) section for instructions on generating this token. +3. Initialize the jpo-utils submodule: + +```sh +git submodule update --init --recursive +``` + +4. Run the following command to start the CV Manager: + +```sh +docker compose up -d +``` + +5. Access the CV Manager webapp at [http://localhost:3000](http://localhost:3000) in your web browser. + +``` +Default Username: test@gmail.com +Default Password: tester +``` + +If you have any issues, try the following steps: + +1. Ensure that the following required services are healthy in Docker (docker ps): + i. cvmanager_postgres + ii. cvmanager_keycloak + iii. cvmanager_api + iv. cvmanager_webapp +2. Bring down the system and re-build all containers + +```sh +docker compose down -v +docker compose up --build -d +``` + +For more details on running the CV-Manager through Docker, see the [Getting Started](#getting-started) section below. + ## Release Notes The current version and release history of the JPO CV Manager: [Release Notes](docs/Release_notes.md) +## Architecture Decision Records + +Significant technical decisions are recorded as [Architecture Decision Records (ADRs)](https://adr.github.io/) in [`docs/adr/`](docs/adr/). Each ADR captures the context, the decision made, the alternatives considered, and the consequences, providing a durable record of *why* the system is built the way it is. + +| ADR | Title | Status | +|-----|-------|--------| +| [ADR-0001](docs/adr/0001-flyway-database-migrations.md) | Flyway for Automated Database Migrations | Accepted | + +When introducing a significant architectural change (a new tool, a change in deployment strategy, a cross-cutting convention), add an ADR to `docs/adr/` following the existing format and update the table above. + ## Requirements and Limitations The JPO CV Manager was originally developed for the Google Cloud Platform and a few of its GCP dependencies still remain. The GCP dependencies will eventually be streamlined to support other options. However, there are a handful of technologies to understand before attempting to utilize the CV Manager. @@ -40,10 +96,31 @@ The JPO CV Manager was originally developed for the Google Cloud Platform and a ### CV Manager Webapp +ReactJS with Redux Toolkit and Mapbox GL + - Supports OAuth2.0 through Keycloak for user authentication only. It can be configured for several different Identity Providers, including Google. +#### Creating a Mapbox Token + +To generate a free mapbox access token: + +1. navigate to https://www.mapbox.com/ and select "Get started for free" +2. Enter your name and email, and choose a unique username +3. Enter your payment information. Your account will remain free unless you surpass the free tier limits (the free tier is very extensive, it will cover everything up to a multi-user large scale deployment) +4. Confirm your email address +5. Enter your billing address +6. After logging in, select the "Tokens" tab under the Admin section +7. Press create a new token (it is easier to manage and re-create new tokens than the default public token) + i. Enter a recognizable token name + ii. No scopes are required, as this token will only be used for tile loading + iii. Under Token Restrictions, enter the URL paths the CV-Manager will be hosted on. For local development, this is http://localhost:3000 and http://${DOCKER_HOST_IP}:3000. This is incredibly important. When the CV-Manager is deployed, the mapbox token can be extracted quite easily. The only way to protect the use of this token (and not incur additional access costs) is to restrict the allowed domains + iv. Select "Create Token" +8. Copy the token value and paste it into the .env under MAPBOX_TOKEN= + ### CV Manager API +Python 3.12.2 + - PostgreSQL database is required. Run the [table creation script to create a to-spec database](resources/sql_scripts). - Follow along with the README to ensure your data is properly populated before running the CV Manager. - GCP BigQuery is required to support J2735 message counts and BSM data. Message counts will be migrated to PostgreSQL eventually, however it is not recommended to store full J2735 messages in a PostgreSQL database. A noSQL database or a database that is specialized for storing big data is recommended. Support for MongoDB is planned to be implemented. @@ -117,13 +194,6 @@ Ease of local development has been a major consideration in the integration of i It should be noted that the `kafka`, `kafka-setup`, `mongo` and `mongo-setup` services are provided by the jpo-utils repository. -**Intersection API Submodules** -The Intersection API uses nested submodules for asn1 encoding and decoding [usdot-jpo-ode/asn1_codec](https://github.com/usdot-jpo-ode/asn1_codec) and kafka management. These submodules need to be initialized and updated before the API can be built and run locally. Run the following command to initialize the submodules: - -```sh -git submodule update --init --recursive -``` - **Running a Simple Local Environment** Build the docker-compose: @@ -207,18 +277,15 @@ The following steps are intended to help get a new user up and running the JPO C git submodule update --init --recursive ``` 3. Create a copy of the sample.env named ".env" and refer to the Environmental variables section below for more information on each variable. - 1. Make sure at least the DOCKER_HOST_IP, KEYCLOAK_ADMIN_PASSWORD, KEYCLOAK_API_CLIENT_SECRET_KEY, and MAPBOX_TOKEN are set for this. - 2. Some of these variables, delineated by sections, pertain to the [jpo-conflictmonitor](https://github.com/usdot-jpo-ode/jpo-conflictmonitor), [jpo-geojsonconverter](https://github.com/usdot-jpo-ode/jpo-geojsonconverter), and [jpo-ode](https://github.com/usdot-jpo-ode/jpo-ode). Please see the documentation provided for these projects when setting these variables. -4. The CV Manager has four components that need to be containerized and deployed: the API, the PostgreSQL database, Keycloak, and the webapp. - - - If you are looking to deploy the CV Manager locally, you can simply run the docker-compose, make sure to fill out the .env file to ensure it launches properly. Also, edit your host file ([How to edit the host file](<[resources/kubernetes](https://docs.rackspace.com/support/how-to/modify-your-hosts-file/)>)) and add IP address of your docker host to these custom domains (remove the carrot brackets and just put the IP address): - - CV Manager hosts: + 1. Make sure at least the DOCKER_HOST_IP, MAVEN_GITHUB_TOKEN, and MAPBOX_TOKEN are set for this. + 2. For other services or different configuration, please make a copy of the sample-full.env. Some of these variables, delineated by sections, pertain to the [jpo-conflictmonitor](https://github.com/usdot-jpo-ode/jpo-conflictmonitor), [jpo-geojsonconverter](https://github.com/usdot-jpo-ode/jpo-geojsonconverter), and [jpo-ode](https://github.com/usdot-jpo-ode/jpo-ode). Please see the documentation provided for these projects when setting these variables. +4. The CV Manager has four core components that need to be built and run: the API, the PostgreSQL database, Keycloak, and the webapp. Ensure that both of the following profiles are specified in the COMPOSE_PROFILES variable of your .env file: - cvmanager.local.com - cvmanager.auth.com + - basic: brings up the API, PostgreSQL, and Keycloak + - webapp: brings up the CV-Manager webapp component + - intersection: Optional, brings up the Intersection API and enables visualization/management of connected intersections -5. Apply the docker compose to start the required components: +5. Use the docker compose to start the required components: ```sh docker compose up -d @@ -230,22 +297,22 @@ The following steps are intended to help get a new user up and running the JPO C docker compose up --build -d ``` -6. Access the website by going to: +6. Access the website by going to http://localhost:3000 ``` - http://cvmanager.local.com Default Username: test@gmail.com Default Password: tester ``` -7. To access keycloak go to: +7. To access keycloak go to http://localhost:8084/ ``` - http://cvmanager.auth.com:8084/ Default Username: admin Default Password: admin ``` + This should automatically redirect you to http://${DOCKER_HOST_IP}:8084/. If it does not, navigate to that URL directly. + - If you are looking to deploy in Kubernetes or on separate VMs, refer to the Kubernetes YAML deployment files to deploy the four components to your cluster. ([Kubernetes YAML](resources/kubernetes)) ### Docker Profiles @@ -257,32 +324,31 @@ In addition to the groups defined in the table below, each service may also be a #### Profiles and Services -| Service | basic | webapp | intersection | intersection_no_api | conflictmonitor | addons | obu_ota | -| ---------------------------------- | ----- | ------ | ------------ | ------------------- | --------------- | ------ | ------- | -| cvmanager_api | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| cvmanager_webapp | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | -| cvmanager_postgres | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| cvmanager_keycloak | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | -| intersection_api | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| conflictmonitor | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| ode | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| aem | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| adm | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| geojsonconverter | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| deduplicator | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| connect | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| jpo_count_metric | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| rsu_status_check | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| jpo_iss_health_check | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| firmware_manager_upgrade_scheduler | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| firmware_manager_upgrade_runner | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | -| jpo_ota_backend | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| jpo_ota_nginx | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| Service | basic | webapp | intersection | conflictmonitor | addons | obu_ota | +| ---------------------------------- | ----- | ------ | ------------ | --------------- | ------ | ------- | +| cvmanager_api | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| cvmanager_webapp | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| cvmanager_postgres | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| cvmanager_keycloak | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| intersection_api | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| conflictmonitor | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| ode | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| aem | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| adm | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| geojsonconverter | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| deduplicator | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| connect | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| jpo_count_metric | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| rsu_status_check | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| jpo_iss_health_check | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| firmware_manager_upgrade_scheduler | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| firmware_manager_upgrade_runner | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| jpo_ota_backend | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| jpo_ota_nginx | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ##### Note on JPO-Utils Profiles -While `kafka`, `kafka-setup`, `mongo`, `mongo-setup`, and `kafka-connect` are not included in the table above, they are required for the intersection API to run. These services -are provided by the jpo-utils repository. To enable these services, you must include the `kafka_full`, `mongo_full`, and `kafka_connect_standalone` profiles. +While `kafka`, `kafka-setup`, `mongo`, `mongo-setup`, and `kafka-connect` are not included in the table above, they are required for the intersection API to run. These services are provided by the jpo-utils repository. To enable these services, you must include the `kafka_full`, `mongo_full`, and `kafka_connect_standalone` profiles. ### Debugging @@ -321,67 +387,23 @@ Note that it is recommended to work with the Python API from a [virtual environm pip install -r services/requirements.txt ``` -#### Setting up a virtual environment with VSCode - -See [Visual Studio Code](https://code.visualstudio.com/docs/python/environments) documentation for information on how to set up a virtual environment with VS Code. - -#### Debugging Profile - -A debugging profile has been set up for use with VSCode to allow ease of debugging with this application. To use this profile, simply open the project in VSCode and select the "Debug" tab on the left side of the screen. Then, select the "Debug Solution" profile and click the green play button. This will spin up a postgresql instance as well as the keycloak auth solution within docker containers. Once running, this will also start the debugger and attach it to the running API container. You can then set breakpoints and step through the code as needed. - -For the "Debug Solution" to run properly on Windows 10/11 using WSL, the following must be configured: - -1. In a Powershell or Command Prompt terminal run the command: `ifconfig` and open up your `C:\Windows\System32\drivers\etc\hosts` file - - - Copy the `Ethernet adapter vEthernet (WSL) -> IPv4 Address` value to your hosts `cvmanager.auth.com` entry. - - In the same hosts file, update the `cvmanager.local.com` value to: `127.0.0.1`. - -2. Update your main .env file variables as specified in the root of the cvmanager directory - - - Copy the `Ethernet adapter vEthernet (Default) -> IPv4 Address` value to your hosts `WEBAPP_HOST_IP` variable - -3. Apply the docker compose to start the required components: - -```sh -docker compose up -d -``` - -To run only the critical cvmanager components (no intersection services), use this command: - -```sh -docker compose up -d cvmanager_api cvmanager_webapp cvmanager_postgres cvmanager_keycloak -``` - -4. Access the website by going to: - - ``` - http://cvmanager.local.com - Default Username: test@gmail.com - Default Password: tester - ``` +### Environment Variables -5. To access keycloak go to: +Required Variables - ``` - http://cvmanager.auth.com:8084/ - Default Username: admin - Default Password: admin - ``` - -### Environment Variables +- DOCKER_HOST_IP: Set with the IP address of the eth0 port in your WSL instance. This can be found by installing networking tools in wsl and running the command `ifconfig` +- MAPBOX_TOKEN: A token from Mapbox used to render the map in the Webapp. The free version of Mapbox works great in most cases. +- MAVEN_GITHUB_TOKEN: A GitHub access token used to access public GitHub Maven packages. See [Github Token](#github-token) section for instructions on generating this token. Generic Variables -- DOCKER_HOST_IP: Set with the IP address of the eth0 port in your WSL instance. This can be found by installing networking tools in wsl and running the command `ifconfig` - WEBAPP_HOST_IP: Defaults to DOCKER_HOST_IP value. Only change this if the webapp is being hosted on a separate endpoint. - KC_HOST_IP: Defaults to DOCKER_HOST_IP value. Only change this if the webapp is being hosted on a separate endpoint. Webapp Variables -- MAPBOX_TOKEN: A token from Mapbox used to render the map in the Webapp. The free version of Mapbox works great in most cases. - WEBAPP_DOMAIN: The domain that the webapp will run on. This is required for Keycloak CORS authentication. - API_URI: The endpoint for the CV manager API, must be on a Keycloak Authorized domain. -- COUNT_MESSAGE_TYPES: List of CV message types to query for counts. - VIEWER_MSG_TYPES: List of CV message types to query geospatially. - DOT_NAME: The name of the DOT using the CV Manager. - MAPBOX_INIT_LATITUDE: Initial latitude value to use for MapBox view state. @@ -390,27 +412,17 @@ docker compose up -d cvmanager_api cvmanager_webapp cvmanager_postgres cvmanager API Variables -- COUNTS_MSG_TYPES: Set to a list of message types to include in counts query. Sample format is described in the sample.env. - MONGO_PROCESSED_BSM_COLLECTION_NAME: The collection name in MongoDB for processed BSM messages. - MONGO_PROCESSED_PSM_COLLECTION_NAME: The collection name in MongoDB for processed PSM messages. -- SSM_DB_NAME: The database name for SSM visualization data. -- SRM_DB_NAME: The database name for SRM visualization data. +- MONGO_SSM_COLLECTION_NAME: The database name for SSM visualization data. +- MONGO_SRM_COLLECTION_NAME: The database name for SRM visualization data. - FIRMWARE_MANAGER_ENDPOINT: Endpoint for the firmware manager deployment's API. -- CSM_EMAIL_TO_SEND_FROM: Origin email address for the API error developer emails. -- CSM_EMAILS_TO_SEND_TO: Destination email addresses for the API error developer emails. -- CSM_EMAIL_APP_USERNAME: Username for the SMTP server. -- CSM_EMAIL_APP_PASSWORD: Password for the SMTP server. -- CSM_TARGET_SMTP_SERVER_ADDRESS: Destination SMTP server address. -- CSM_TARGET_SMTP_SERVER_PORT: Destination SMTP server port. +- IAPI_ENDPOINT: Intersection API endpoint for making REST requests to send emails +- KC_SA_CLIENT_ID: Keycloak service account client ID for generating authenticating to the Intersection API +- KC_SA_CLIENT_SECRET: Keycloak service account client secret for generating authenticating to the Intersection API - API_LOGGING_LEVEL: The level of which the CV Manager API will log. (DEBUG, INFO, WARNING, ERROR) -- CSM_TLS_ENABLED: Set to "true" if the SMTP server requires TLS. -- CSM_AUTH_ENABLED: Set to "true" if the SMTP server requires authentication. - WZDX_ENDPOINT: WZDX datafeed endpoint. - WZDX_API_KEY: API key for the WZDX datafeed. -- GOOGLE_ACCESS_KEY_NAME: The required Google environment variable for authenticating with Google Cloud. -- GCP_PROJECT_ID: The Google Cloud project ID for which the service account associated with GOOGLE_ACCESS_KEY_NAME is for. -- MOOVE_AI_SEGMENT_AGG_STATS_TABLE: The BigQuery table name for Moove.Ai's segment aggregate statistics. -- MOOVE_AI_SEGMENT_EVENT_STATS_TABLE: The BigQuery table name for Moove.Ai's segment event statistics. - TIMEZONE: Timezone to be used for the API. - GOOGLE_APPLICATION_CREDENTIALS: Path to the GCP service account credentials file. Attached as a volume to the CV manager API service. @@ -447,7 +459,7 @@ git config --global core.autocrlf false - KEYCLOAK_API_CLIENT_ID: Keycloak API client name. - KEYCLOAK_API_CLIENT_SECRET_KEY: Keycloak API secret for the given client name. - KEYCLOAK_LOGIN_THEME_NAME: Name of the jar file to use as the theme provider in Keycloak. For generating a custom theme reference the [Keycloakify](https://github.com/CDOT-CV/keycloakify-starter) Github -- KC_LOGGING_LEVEL: The level of which the Keycloak instance will log. (ALL, DEBUG, ERROR, FATAL, INFO, OFF, TRACE, and WARN) +- KC_LOG_LEVEL: The level of which the Keycloak instance will log. (ALL, DEBUG, ERROR, FATAL, INFO, OFF, TRACE, and WARN) - GOOGLE_CLIENT_ID: GCP OAuth2.0 client ID for SSO Authentication within keycloak. - GOOGLE_CLIENT_SECRET: GCP OAuth2.0 client secret for SSO Authentication within keycloak. @@ -468,6 +480,36 @@ On Windows, Disable `git core.autocrlf` (One Time Only) git config --global core.autocrlf false ``` +### CV Manager Common Issues + +1. After logging into the webapp, you are presented with a page reading "Login Unsuccessful: Unknown Error Occurred" + Login Unsuccessful: Unknown Error Occurred + This indicates an issue within the cvmanager_api service, see the docker logs for more information. Common issues include: + i. Unable to connect to PostgreSQL server (see postgres logs) + ii. Keycloak authentication error (see keycloak logs) +2. The webapp needs to be re-build after each environment variable change + This is due to the fact that environment variables are injected into the Docker image at _BUILD_ time, not runtime. + +```sh +docker compose up --build -d cvmanager_webapp +``` + +3. The Keycloak Hostname needs to be accessible from docker and a browser + Keycloak needs to be accessible other docker containers (cvmanager_api, intersection_api) as well as the webapp (running in a browser). This means that using "localhost" will not work (not accessible from other docker containers), and using "", the docker container network name, will not work either (not accessible in the browser since it is outside of the docker network). This is why the suggested approach is to set your docker host IP address as your keycloak hostname. Keycloak will redirect any incomming connection to the hostname, therefore you cannot set the hostname to "localhost" and have docker services access it at cvmanager_keycloak:8080. +4. The Keycloak volume must be cleared if crucial parameters are changed + If the keycloak admin user credentials, client id(s), client secret, or webapp endpoint are changed by environment variable, those changes will not be reflected in keycloak unless the volume is cleared and re-built. This can be done by: + +```sh +docker compose down -v +docker compose up -d +``` + +### SMTP Email Testing + +For testing Intersection API email generation, a local SMTP server should be used. The recommended server is [smtp4dev](https://github.com/rnwood/smtp4dev). + +This can be used by enabling the `smtp4dev` profile in the docker-compose-intersection.yml file. This will start smtp4dev on ports 5000 (web UI), 25 (SMTP), 143 (IMAP), and 110 (POP3). + ## License Information Licensed under the Apache License, Version 2.0 (the "License"); you may not use this diff --git a/docker-compose-addons.yml b/docker-compose-addons.yml index ef59efd39..2ed8a83c3 100644 --- a/docker-compose-addons.yml +++ b/docker-compose-addons.yml @@ -9,32 +9,32 @@ services: image: count_metric:latest restart: on-failure:3 environment: - ENABLE_EMAILER: ${ENABLE_EMAILER} - DEPLOYMENT_TITLE: ${DEPLOYMENT_TITLE} + ENABLE_EMAILER: ${ENABLE_EMAILER:-} + DEPLOYMENT_TITLE: ${DEPLOYMENT_TITLE:-} - SMTP_SERVER_IP: ${SMTP_SERVER_IP} - SMTP_USERNAME: ${SMTP_USERNAME} - SMTP_PASSWORD: ${SMTP_PASSWORD} - SMTP_EMAIL: ${SMTP_EMAIL} + IAPI_ENDPOINT: ${IAPI_ENDPOINT:-http://${DOCKER_HOST_IP}:8089} + KC_ENDPOINT: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + KC_REALM: ${KEYCLOAK_REALM:-cvmanager} + KC_SA_CLIENT_ID: ${KEYCLOAK_SA_COUNT_METRIC_CLIENT_ID:-sa_count_metric} + KC_SA_CLIENT_SECRET: ${KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY:-sa-count-metric-secret-key} - MESSAGE_TYPES: ${COUNT_MESSAGE_TYPES} - PROJECT_ID: ${GCP_PROJECT_ID} + PROJECT_ID: ${GCP_PROJECT_ID:-} - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_NAME: ${PG_DB_NAME} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres:5432} + PG_DB_NAME: ${PG_DB_NAME:-postgres} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} - DESTINATION_DB: ${COUNT_DESTINATION_DB} + DESTINATION_DB: ${COUNT_DESTINATION_DB:-MONGODB} - MONGO_DB_URI: ${MONGO_DB_URI} - MONGO_DB_NAME: ${CM_DATABASE_NAME} - INPUT_COUNTS_MONGO_COLLECTION_NAME: ${INPUT_COUNTS_MONGO_COLLECTION_NAME} - OUTPUT_COUNTS_MONGO_COLLECTION_NAME: ${OUTPUT_COUNTS_MONGO_COLLECTION_NAME} + MONGO_DB_URI: ${MONGO_DB_URI:-mongodb://${MONGO_READ_WRITE_USER:-ode}:${MONGO_READ_WRITE_PASS:-replace-me}@${DB_HOST_IP:-mongo}:${DB_HOST_PORT:-27017}/?directConnection=true&authSource=admin} + MONGO_DB_NAME: ${CM_DATABASE_NAME:-CV} + INPUT_COUNTS_MONGO_COLLECTION_NAME: ${INPUT_COUNTS_MONGO_COLLECTION_NAME:-} + OUTPUT_COUNTS_MONGO_COLLECTION_NAME: ${OUTPUT_COUNTS_MONGO_COLLECTION_NAME:-} - KAFKA_BIGQUERY_TABLENAME: ${KAFKA_BIGQUERY_TABLENAME} + KAFKA_BIGQUERY_TABLENAME: ${KAFKA_BIGQUERY_TABLENAME:-} - LOGGING_LEVEL: ${COUNTS_LOGGING_LEVEL} + LOGGING_LEVEL: ${COUNTS_LOGGING_LEVEL:-} logging: options: max-size: '10m' @@ -50,24 +50,24 @@ services: image: rsu_status_check:latest restart: on-failure:3 environment: - RSU_PING: ${RSU_PING} - ZABBIX: ${ZABBIX} - RSU_MSGFWD_FETCH: ${RSU_MSGFWD_FETCH} - RSU_SECURITY_FETCH: ${RSU_SECURITY_FETCH} - RSU_HEALTH_FETCH: ${RSU_HEALTH_FETCH} + RSU_PING: ${RSU_PING:-True} + ZABBIX: ${ZABBIX:-False} + RSU_MSGFWD_FETCH: ${RSU_MSGFWD_FETCH:-True} + RSU_SECURITY_FETCH: ${RSU_SECURITY_FETCH:-True} + RSU_HEALTH_FETCH: ${RSU_HEALTH_FETCH:-True} - ZABBIX_ENDPOINT: ${ZABBIX_ENDPOINT} - ZABBIX_USER: ${ZABBIX_USER} - ZABBIX_PASSWORD: ${ZABBIX_PASSWORD} + ZABBIX_ENDPOINT: ${ZABBIX_ENDPOINT:-} + ZABBIX_USER: ${ZABBIX_USER:-} + ZABBIX_PASSWORD: ${ZABBIX_PASSWORD:-} - STALE_PERIOD: ${STALE_PERIOD} + STALE_PERIOD: ${STALE_PERIOD:-24} - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_NAME: ${PG_DB_NAME} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres:5432} + PG_DB_NAME: ${PG_DB_NAME:-postgres} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} - LOGGING_LEVEL: ${RSU_STATUS_LOGGING_LEVEL} + LOGGING_LEVEL: ${RSU_STATUS_LOGGING_LEVEL:-} logging: options: max-size: '10m' @@ -87,26 +87,26 @@ services: condition: service_healthy required: false environment: - STORAGE_TYPE: ${STORAGE_TYPE} + STORAGE_TYPE: ${STORAGE_TYPE:-postgres} - ISS_API_KEY: ${ISS_API_KEY} - ISS_API_KEY_NAME: ${ISS_API_KEY_NAME} - ISS_PROJECT_ID: ${ISS_PROJECT_ID} - ISS_SCMS_TOKEN_REST_ENDPOINT: ${ISS_SCMS_TOKEN_REST_ENDPOINT} - ISS_SCMS_VEHICLE_REST_ENDPOINT: ${ISS_SCMS_VEHICLE_REST_ENDPOINT} - ISS_KEY_TABLE_NAME: ${ISS_KEY_TABLE_NAME} + ISS_API_KEY: ${ISS_API_KEY:-} + ISS_API_KEY_NAME: ${ISS_API_KEY_NAME:-} + ISS_PROJECT_ID: ${ISS_PROJECT_ID:-} + ISS_SCMS_TOKEN_REST_ENDPOINT: ${ISS_SCMS_TOKEN_REST_ENDPOINT:-} + ISS_SCMS_VEHICLE_REST_ENDPOINT: ${ISS_SCMS_VEHICLE_REST_ENDPOINT:-} + ISS_KEY_TABLE_NAME: ${ISS_KEY_TABLE_NAME:-} - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_NAME: ${PG_DB_NAME} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres:5432} + PG_DB_NAME: ${PG_DB_NAME:-postgres} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} - PROJECT_ID: ${GCP_PROJECT_ID} + PROJECT_ID: ${GCP_PROJECT_ID:-} GOOGLE_APPLICATION_CREDENTIALS: /home/gcp_key.json - LOGGING_LEVEL: ${ISS_LOGGING_LEVEL} + LOGGING_LEVEL: ${ISS_LOGGING_LEVEL:-} volumes: - - ./resources/google/${GOOGLE_ACCESS_KEY_NAME}:/home/gcp_key.json + - ./resources/google/${GOOGLE_ACCESS_KEY_NAME:-}:/home/gcp_key.json logging: options: max-size: '10m' @@ -123,18 +123,18 @@ services: restart: on-failure:3 ports: - - '8089:8080' + - '8091:8080' environment: - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_NAME: postgres - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres:5432} + PG_DB_NAME: ${PG_DB_NAME:-postgres} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} - UPGRADE_RUNNER_ENDPOINT: ${FIRMWARE_MANAGER_UPGRADE_RUNNER_ENDPOINT} + UPGRADE_RUNNER_ENDPOINT: ${FIRMWARE_MANAGER_UPGRADE_RUNNER_ENDPOINT:-http://firmware_manager_upgrade_runner:8080} - LOGGING_LEVEL: ${FIRMWARE_MANAGER_LOGGING_LEVEL} + LOGGING_LEVEL: ${FIRMWARE_MANAGER_LOGGING_LEVEL:-} volumes: - - ${HOST_BLOB_STORAGE_DIRECTORY}:/mnt/blob_storage + - ${HOST_BLOB_STORAGE_DIRECTORY:-./local_blob_storage}:/mnt/blob_storage logging: options: max-size: '10m' @@ -153,25 +153,26 @@ services: ports: - '8090:8080' environment: - BLOB_STORAGE_PROVIDER: ${BLOB_STORAGE_PROVIDER} - BLOB_STORAGE_BUCKET: ${BLOB_STORAGE_BUCKET} + BLOB_STORAGE_PROVIDER: ${BLOB_STORAGE_PROVIDER:-DOCKER} + BLOB_STORAGE_BUCKET: ${BLOB_STORAGE_BUCKET:-DOCKER} - FW_UPGRADE_MAX_RETRY_LIMIT: ${FW_UPGRADE_MAX_RETRY_LIMIT} + FW_UPGRADE_MAX_RETRY_LIMIT: ${FW_UPGRADE_MAX_RETRY_LIMIT:-3} - GCP_PROJECT: ${GCP_PROJECT_ID} + GCP_PROJECT: ${GCP_PROJECT_ID:-} GOOGLE_APPLICATION_CREDENTIALS: /home/gcp_key.json - SMTP_SERVER_IP: ${SMTP_SERVER_IP} - SMTP_EMAIL: ${SMTP_EMAIL} - SMTP_USERNAME: ${SMTP_USERNAME} - SMTP_PASSWORD: ${SMTP_PASSWORD} + IAPI_ENDPOINT: ${IAPI_ENDPOINT:-http://${DOCKER_HOST_IP}:8089} + KC_ENDPOINT: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + KC_REALM: ${KEYCLOAK_REALM:-cvmanager} + KC_SA_CLIENT_ID: ${KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_ID:-sa_firmware_upgrade_runner} + KC_SA_CLIENT_SECRET: ${KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY:-sa-firmware-upgrade-runner-secret-key} - UPGRADE_SCHEDULER_ENDPOINT: ${FIRMWARE_MANAGER_UPGRADE_SCHEDULER_ENDPOINT} + UPGRADE_SCHEDULER_ENDPOINT: ${FIRMWARE_MANAGER_UPGRADE_SCHEDULER_ENDPOINT:-http://firmware_manager_upgrade_scheduler:8090} - LOGGING_LEVEL: ${FIRMWARE_MANAGER_LOGGING_LEVEL} + LOGGING_LEVEL: ${FIRMWARE_MANAGER_LOGGING_LEVEL:-} volumes: - - ./resources/google/${GOOGLE_ACCESS_KEY_NAME}:/home/gcp_key.json - - ${HOST_BLOB_STORAGE_DIRECTORY}:/mnt/blob_storage + - ./resources/google/${GOOGLE_ACCESS_KEY_NAME:-sample_gcp_service_account.json}:/home/gcp_key.json + - ${HOST_BLOB_STORAGE_DIRECTORY:-./local_blob_storage}:/mnt/blob_storage logging: options: max-size: '10m' diff --git a/docker-compose-conflictmonitor.yml b/docker-compose-conflictmonitor.yml index 1e04dd42c..8ef75481e 100644 --- a/docker-compose-conflictmonitor.yml +++ b/docker-compose-conflictmonitor.yml @@ -8,14 +8,14 @@ services: - conflictmonitor - conflictmonitor_only image: ${DOCKERHUB_HOST:-usdotjpoode}/jpo-conflictmonitor:${DOCKERHUB_RELEASE:-2025-q2} - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} ports: - '8082:8082' environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP:?error} - KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:?error} + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} + KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092} CONNECT_URL: ${CONNECT_URL:-http://kafka-connect:8083} - spring.kafka.bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:?error} + spring.kafka.bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092} ROCKSDB_TOTAL_OFF_HEAP_MEMORY: ${ROCKSDB_TOTAL_OFF_HEAP_MEMORY:-134217728} ROCKSDB_INDEX_FILTER_BLOCK_RATIO: ${ROCKSDB_INDEX_FILTER_BLOCK_RATIO:-0.1} ROCKSDB_TOTAL_MEMTABLE_MEMORY: ${ROCKSDB_TOTAL_MEMTABLE_MEMORY:-67108864} @@ -47,7 +47,7 @@ services: - conflictmonitor - ode image: ${DOCKERHUB_HOST:-usdotjpoode}/jpo-ode:${DOCKERHUB_RELEASE:-2025-q2} - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} ports: - '8080:8080' - '9090:9090' @@ -63,8 +63,8 @@ services: - '5555:5555/udp' - '6666:6666/udp' environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP:?error} - ODE_KAFKA_BROKERS: ${DOCKER_HOST_IP}:9092 + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} + ODE_KAFKA_BROKERS: ${DOCKER_HOST_IP:-kafka}:9092 KAFKA_LINGER_MS: 1 KAFKA_ACKS: all KAFKA_RETRIES: 0 @@ -105,14 +105,14 @@ services: - conflictmonitor - adm image: ${DOCKERHUB_HOST:-usdotjpoode}/asn1_codec:${DOCKERHUB_RELEASE:-2025-q2} - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} deploy: resources: limits: cpus: '1' memory: 2G environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP} + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} ACM_CONFIG_FILE: adm.properties ACM_LOG_TO_CONSOLE: false ACM_LOG_TO_FILE: true @@ -131,14 +131,14 @@ services: - conflictmonitor - aem image: ${DOCKERHUB_HOST:-usdotjpoode}/asn1_codec:${DOCKERHUB_RELEASE:-2025-q2} - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} deploy: resources: limits: cpus: '1' memory: 2G environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP} + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} ACM_CONFIG_FILE: aem.properties ACM_LOG_TO_CONSOLE: false ACM_LOG_TO_FILE: true @@ -157,16 +157,16 @@ services: - conflictmonitor - geojsonconverter image: ${DOCKERHUB_HOST:-usdotjpoode}/geojsonconverter:${DOCKERHUB_RELEASE:-2025-q2} - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} deploy: resources: limits: cpus: '1' memory: 2G environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP:?error} + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} geometry.output.mode: GEOJSON_ONLY - spring.kafka.bootstrap-servers: ${DOCKER_HOST_IP:?error}:9092 + spring.kafka.bootstrap-servers: ${DOCKER_HOST_IP:-kafka}:9092 logging: options: max-size: '10m' @@ -182,13 +182,13 @@ services: - deduplicator image: ${DOCKERHUB_HOST:-usdotjpoode}/jpo-deduplicator:${DOCKERHUB_RELEASE:-2025-q2} privileged: false # Set true to allow writing to /proc/sys/vm/drop_caches - restart: ${RESTART_POLICY} + restart: ${RESTART_POLICY:-on-failure:3} ports: - '10091:10090' # JMX environment: - DOCKER_HOST_IP: ${DOCKER_HOST_IP} - KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS} - SPRING_KAFKA_BOOTSTRAPSERVERS: ${KAFKA_BOOTSTRAP_SERVERS} + DOCKER_HOST_IP: ${DOCKER_HOST_IP:-host.docker.internal} + KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092} + SPRING_KAFKA_BOOTSTRAPSERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092} ENABLE_PROCESSED_MAP_DEDUPLICATION: true ENABLE_PROCESSED_MAP_WKT_DEDUPLICATION: true ENABLE_ODE_MAP_DEDUPLICATION: true diff --git a/docker-compose-intersection.yml b/docker-compose-intersection.yml index f727a8e21..62e8486fe 100644 --- a/docker-compose-intersection.yml +++ b/docker-compose-intersection.yml @@ -13,45 +13,61 @@ services: dockerfile: Dockerfile.intersection-api args: MAVEN_GITHUB_TOKEN: ${MAVEN_GITHUB_TOKEN:?error} - MAVEN_GITHUB_ORG: ${MAVEN_GITHUB_ORG:?error} + MAVEN_GITHUB_ORG: ${MAVEN_GITHUB_ORG:-usdot-jpo-ode} ports: - '8089:8089' restart: always - extra_hosts: - ${WEBAPP_DOMAIN}: ${WEBAPP_HOST_IP} - ${KEYCLOAK_DOMAIN}: ${KC_HOST_IP} environment: - AUTH_SERVER_URL: ${KEYCLOAK_ENDPOINT} - DB_HOST_IP: ${DB_HOST_IP} - DB_HOST_PORT: ${DB_HOST_PORT} - SPRING_KAFKA_BOOTSTRAPSERVERS: ${KAFKA_BOOTSTRAP_SERVERS} - CM_SERVER_URL: ${CM_SERVER_URL} + AUTH_SERVER_URL: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + DB_HOST_IP: ${DB_HOST_IP:-mongo} + DB_HOST_PORT: ${DB_HOST_PORT:-27017} + SPRING_KAFKA_BOOTSTRAPSERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092} + CM_SERVER_URL: ${CM_SERVER_URL:-http://conflictmonitor:8082} load: 'false' KAFKA_TYPE: 'ON-PREM' ACM_CONFIG_FILE: adm.properties ACM_LOG_TO_CONSOLE: true ACM_LOG_TO_FILE: false ACM_LOG_LEVEL: DEBUG - MONGO_READ_WRITE_USER: ${MONGO_READ_WRITE_USER} - MONGO_READ_WRITE_PASS: ${MONGO_READ_WRITE_PASS} - CM_MAXIMUM_RESPONSE_SIZE: ${CM_MAXIMUM_RESPONSE_SIZE} - SPRING_DATA_MONGODB_DATABASE: ${CM_DATABASE_NAME} + MONGO_READ_WRITE_USER: ${MONGO_READ_WRITE_USER:-ode} + MONGO_READ_WRITE_PASS: ${MONGO_READ_WRITE_PASS:-replace_me} + CM_MAXIMUM_RESPONSE_SIZE: ${CM_MAXIMUM_RESPONSE_SIZE:-10000} + SPRING_DATA_MONGODB_DATABASE: ${CM_DATABASE_NAME:-CV} - KEYCLOAK_CLIENT_API_ID: ${KEYCLOAK_API_CLIENT_ID} - KEYCLOAK_CLIENT_API_SECRET: ${KEYCLOAK_API_CLIENT_SECRET_KEY} + KEYCLOAK_CLIENT_API_ID: ${KEYCLOAK_API_CLIENT_ID:-cvmanager-api} + KEYCLOAK_CLIENT_API_SECRET: ${KEYCLOAK_CLIENT_API_SECRET_KEY:-keycloak-secret-key} - KAFKA_BROKER_IP: ${KAFKA_BROKER_IP} - KAFKA_BROKER_PORT: ${KAFKA_BROKER_PORT} + KAFKA_BROKER_IP: ${KAFKA_BROKER_IP:-kafka} + KAFKA_BROKER_PORT: ${KAFKA_BROKER_PORT:-5432} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} - POSTGRES_SERVER_URL: jdbc:postgresql://${PG_DB_HOST} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} + POSTGRES_SERVER_URL: jdbc:postgresql://${PG_DB_HOST:-cvmanager_postgres:5432} - INTERSECTION_API_ENABLE_API: ${INTERSECTION_API_ENABLE_API} - INTERSECTION_API_ENABLE_EMAILER: ${INTERSECTION_API_ENABLE_EMAILER} - INTERSECTION_API_ENABLE_REPORTS: ${INTERSECTION_API_ENABLE_REPORTS} + INTERSECTION_API_ENABLE_API: ${INTERSECTION_API_ENABLE_API:-True} + INTERSECTION_API_ENABLE_EMAILER: ${INTERSECTION_API_ENABLE_EMAILER:-true} + INTERSECTION_API_ENABLE_REPORTS: ${INTERSECTION_API_ENABLE_REPORTS:-false} + INTERSECTION_API_ENABLE_HAAS: ${INTERSECTION_API_ENABLE_HAAS:-false} + INTERSECTION_API_ENABLE_ASN1_DECODER: ${INTERSECTION_API_ENABLE_ASN1_DECODER:-true} + + FIRMWARE_MANAGER_ENDPOINT: ${FIRMWARE_MANAGER_ENDPOINT:-http://firmware_manager_upgrade_scheduler:8080} BASE_ROUTE_PREFIX: ${INTERSECTION_API_ROUTE_PREFIX:-/} + + INTERSECTION_EMAIL_BROKER: ${INTERSECTION_EMAIL_BROKER:-SMTP} + INTERSECTION_SENDER_EMAIL: ${INTERSECTION_SENDER_EMAIL:-donotreply@cvmanager.com} + UNSUBSCRIBE_SECRET_KEY: ${UNSUBSCRIBE_SECRET_KEY:-b5f9070ca58d4576ea6a2d8d4c1b97caf8abc2554198f8f9acf86d0d31a52a52} + CV_MANAGER_FRONT_END_URI: ${CV_MANAGER_FRONT_END_URI:-http://localhost:${WEBAPP_PORT:-3000}} + INTERSECTION_SMTP_SERVER_HOST: ${INTERSECTION_SMTP_SERVER_HOST:-smtp4dev} + INTERSECTION_SMTP_SERVER_PORT: ${INTERSECTION_SMTP_SERVER_PORT:-25} + INTERSECTION_SMTP_USERNAME: ${INTERSECTION_SMTP_USERNAME:-admin} + INTERSECTION_SMTP_PASSWORD: ${INTERSECTION_SMTP_PASSWORD:-password} + INTERSECTION_SMTP_AUTH: ${INTERSECTION_SMTP_AUTH_ENABLED:-true} + INTERSECTION_SMTP_STARTTLS: ${INTERSECTION_SMTP_STARTTLS_ENABLED:-false} + SENDGRID_API_KEY: ${SENDGRID_API_KEY} + POSTMARK_API_KEY: ${POSTMARK_API_KEY} + EMAIL_RATE_LIMIT_PER_USER: ${EMAIL_RATE_LIMIT_PER_USER:-12} + EMAIL_RATE_LIMIT_PER_INSTANCE: ${EMAIL_RATE_LIMIT_PER_INSTANCE:-120} entrypoint: - sh - -c @@ -71,3 +87,17 @@ services: kafka-setup: condition: service_started required: false + smtp4dev: + profiles: + - smtp4dev + image: rnwood/smtp4dev:v3 + restart: always + ports: + - '5000:80' # Web UI + - '25:25' # SMTP + environment: + # Enable SMTP authentication + - ServerOptions__AuthenticationRequired=true + # Set username and password for SMTP auth + - ServerOptions__Users__0__Username=${SMTP4DEV_USERNAME:-admin} + - ServerOptions__Users__0__Password=${SMTP4DEV_PASSWORD:-password} diff --git a/docker-compose-obu-ota-server.yml b/docker-compose-obu-ota-server.yml index f6c4b0f5a..d39f5dc82 100644 --- a/docker-compose-obu-ota-server.yml +++ b/docker-compose-obu-ota-server.yml @@ -12,22 +12,22 @@ services: ports: - 8085:8085 environment: - SERVER_HOST: ${OBU_OTA_SERVER_HOST} - LOGGING_LEVEL: ${OBU_OTA_LOGGING_LEVEL} - BLOB_STORAGE_PROVIDER: ${BLOB_STORAGE_PROVIDER} - BLOB_STORAGE_BUCKET: ${OBU_OTA_BLOB_STORAGE_BUCKET} - BLOB_STORAGE_PATH: ${OBU_OTA_BLOB_STORAGE_PATH} + SERVER_HOST: ${OBU_OTA_SERVER_HOST:-} + LOGGING_LEVEL: ${OBU_OTA_LOGGING_LEVEL:-} + BLOB_STORAGE_PROVIDER: ${BLOB_STORAGE_PROVIDER:-DOCKER} + BLOB_STORAGE_BUCKET: ${OBU_OTA_BLOB_STORAGE_BUCKET:-} + BLOB_STORAGE_PATH: ${OBU_OTA_BLOB_STORAGE_PATH:-} - OTA_USERNAME: ${OTA_USERNAME} - OTA_PASSWORD: ${OTA_PASSWORD} + OTA_USERNAME: ${OTA_USERNAME:-admin} + OTA_PASSWORD: ${OTA_PASSWORD:-admin} - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_NAME: ${PG_DB_NAME} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} + PG_DB_NAME: ${PG_DB_NAME:-postgres} - MAX_COUNT: ${MAX_COUNT} - NGINX_ENCRYPTION: ${NGINX_ENCRYPTION} + MAX_COUNT: ${MAX_COUNT:-10} + NGINX_ENCRYPTION: ${NGINX_ENCRYPTION:-plain} volumes: - ./resources/ota/firmwares:/firmwares logging: @@ -48,12 +48,12 @@ services: - 443:443 environment: NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx - SERVER_HOST: ${OBU_OTA_SERVER_HOST} + SERVER_HOST: ${OBU_OTA_SERVER_HOST:-} volumes: - - ./resources/ota/nginx/nginx-${NGINX_ENCRYPTION}.conf:/etc/nginx/templates/nginx.conf.template + - ./resources/ota/nginx/nginx-${NGINX_ENCRYPTION:-plain}.conf:/etc/nginx/templates/nginx.conf.template - ./resources/ota/nginx/gen_dhparam.sh:/docker-entrypoint.d/gen_dhparam.sh - - ./resources/ota/nginx/ssl/${SERVER_CERT_FILE}:/etc/ssl/certs/ota_server.crt - - ./resources/ota/nginx/ssl/${SERVER_KEY_FILE}:/etc/ssl/private/ota_server.key + - ./resources/ota/nginx/ssl/${SERVER_CERT_FILE:-ota_server.crt}:/etc/ssl/certs/ota_server.crt + - ./resources/ota/nginx/ssl/${SERVER_KEY_FILE:-ota_server.key}:/etc/ssl/private/ota_server.key depends_on: - jpo_ota_backend logging: diff --git a/docker-compose-webapp-deployment.yml b/docker-compose-webapp-deployment.yml index 39fddf3b1..431b2e9f0 100644 --- a/docker-compose-webapp-deployment.yml +++ b/docker-compose-webapp-deployment.yml @@ -1,5 +1,4 @@ # This file is used to build the webapp image for deployment. -# The COUNTS_MSG_TYPES and DOT_NAME variables must be set in .env before building to populate # correctly in the deployed webapp as they are build-time variables. services: cvmanager_webapp: @@ -7,20 +6,19 @@ services: context: webapp dockerfile: Dockerfile args: - API_URI: ${API_ENDPOINT} - MAPBOX_TOKEN: ${MAPBOX_TOKEN} - KEYCLOAK_HOST_URL: ${KEYCLOAK_ENDPOINT} - COUNT_MESSAGE_TYPES: ${COUNTS_MSG_TYPES} - VIEWER_MESSAGE_TYPES: ${VIEWER_MSG_TYPES} - DOT_NAME: ${DOT_NAME} - MAPBOX_INIT_LATITUDE: ${MAPBOX_INIT_LATITUDE} - MAPBOX_INIT_LONGITUDE: ${MAPBOX_INIT_LONGITUDE} - MAPBOX_INIT_ZOOM: ${MAPBOX_INIT_ZOOM} + API_URI: ${API_ENDPOINT:?error} + MAPBOX_TOKEN: ${MAPBOX_TOKEN:?error} + KEYCLOAK_HOST_URL: ${KEYCLOAK_ENDPOINT:?error} + VIEWER_MESSAGE_TYPES: ${VIEWER_MSG_TYPES:-BSM} + DOT_NAME: ${DOT_NAME:-CDOT} + MAPBOX_INIT_LATITUDE: ${MAPBOX_INIT_LATITUDE:-39.7392} + MAPBOX_INIT_LONGITUDE: ${MAPBOX_INIT_LONGITUDE:--104.9903} + MAPBOX_INIT_ZOOM: ${MAPBOX_INIT_ZOOM:-10} CVIZ_API_SERVER_URL: ${CVIZ_API_SERVER_URL} CVIZ_API_WS_URL: ${CVIZ_API_WS_URL} - ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES} - ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES} - ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES} + ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES:-True} + ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES:-True} + ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES:-True} image: jpo_cvmanager_webapp:latest restart: always logging: diff --git a/docker-compose.yml b/docker-compose.yml index bd06227bc..81175e67f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,27 +14,22 @@ services: dockerfile: Dockerfile.api image: jpo_cvmanager_api:latest restart: always - extra_hosts: - ${WEBAPP_DOMAIN}: ${WEBAPP_HOST_IP} - ${KEYCLOAK_DOMAIN}: ${KC_HOST_IP} ports: - '8081:5000' environment: - PG_DB_HOST: ${PG_DB_HOST} - PG_DB_USER: ${PG_DB_USER} - PG_DB_PASS: ${PG_DB_PASS} - PG_DB_NAME: postgres + PG_DB_HOST: ${PG_DB_HOST:-cvmanager_postgres:5432} + PG_DB_USER: ${PG_DB_USER:-postgres} + PG_DB_PASS: ${PG_DB_PASS:-postgres} + PG_DB_NAME: ${PG_DB_NAME:-postgres} INSTANCE_CONNECTION_NAME: ${INSTANCE_CONNECTION_NAME} - MONGO_DB_URI: ${MONGO_DB_URI} - MONGO_DB_NAME: ${CM_DATABASE_NAME} - MONGO_PROCESSED_BSM_COLLECTION_NAME: ${MONGO_PROCESSED_BSM_COLLECTION_NAME} - MONGO_PROCESSED_PSM_COLLECTION_NAME: ${MONGO_PROCESSED_PSM_COLLECTION_NAME} + MONGO_DB_URI: ${MONGO_DB_URI:-mongodb://${MONGO_READ_WRITE_USER:-ode}:${MONGO_READ_WRITE_PASS:-replace-me}@${DB_HOST_IP:-mongo}:${DB_HOST_PORT:-27017}/?directConnection=true&authSource=admin} + MONGO_DB_NAME: ${CM_DATABASE_NAME:-CV} + MONGO_PROCESSED_BSM_COLLECTION_NAME: ${MONGO_PROCESSED_BSM_COLLECTION_NAME:-processed_bsm} + MONGO_PROCESSED_PSM_COLLECTION_NAME: ${MONGO_PROCESSED_PSM_COLLECTION_NAME:-processed_psm} - COUNTS_MSG_TYPES: ${COUNTS_MSG_TYPES} - - SSM_DB_NAME: ${SSM_DB_NAME} - SRM_DB_NAME: ${SRM_DB_NAME} + MONGO_SSM_COLLECTION_NAME: ${MONGO_SSM_COLLECTION_NAME} + MONGO_SRM_COLLECTION_NAME: ${MONGO_SRM_COLLECTION_NAME} MAX_GEO_QUERY_RECORDS: ${MAX_GEO_QUERY_RECORDS} @@ -43,38 +38,38 @@ services: WZDX_API_KEY: ${WZDX_API_KEY} WZDX_ENDPOINT: ${WZDX_ENDPOINT} - ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES} - ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES} - ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES} - ENABLE_MOOVE_AI_FEATURES: ${ENABLE_MOOVE_AI_FEATURES} - - CORS_DOMAIN: ${CORS_DOMAIN} - KEYCLOAK_ENDPOINT: ${KEYCLOAK_ENDPOINT} - KEYCLOAK_REALM: ${KEYCLOAK_REALM} - KEYCLOAK_API_CLIENT_ID: ${KEYCLOAK_API_CLIENT_ID} - KEYCLOAK_API_CLIENT_SECRET_KEY: ${KEYCLOAK_API_CLIENT_SECRET_KEY} - - CSM_EMAIL_TO_SEND_FROM: ${CSM_EMAIL_TO_SEND_FROM} - CSM_EMAILS_TO_SEND_TO: ${CSM_EMAILS_TO_SEND_TO} - CSM_TARGET_SMTP_SERVER_ADDRESS: ${CSM_TARGET_SMTP_SERVER_ADDRESS} - CSM_TARGET_SMTP_SERVER_PORT: ${CSM_TARGET_SMTP_SERVER_PORT} - CSM_TLS_ENABLED: ${CSM_TLS_ENABLED} - CSM_AUTH_ENABLED: ${CSM_AUTH_ENABLED} - CSM_EMAIL_APP_USERNAME: ${CSM_EMAIL_APP_USERNAME} - CSM_EMAIL_APP_PASSWORD: ${CSM_EMAIL_APP_PASSWORD} - - ENVIRONMENT_NAME: ${ENVIRONMENT_NAME} - LOGS_LINK: ${LOGS_LINK} - - GOOGLE_APPLICATION_CREDENTIALS: /home/gcp_key.json - GCP_PROJECT_ID: ${GCP_PROJECT_ID} - MOOVE_AI_SEGMENT_AGG_STATS_TABLE: ${MOOVE_AI_SEGMENT_AGG_STATS_TABLE} - MOOVE_AI_SEGMENT_EVENT_STATS_TABLE: ${MOOVE_AI_SEGMENT_EVENT_STATS_TABLE} - - TIMEZONE: ${TIMEZONE} - LOGGING_LEVEL: ${API_LOGGING_LEVEL} - volumes: - - ./resources/google/${GOOGLE_ACCESS_KEY_NAME}:/home/gcp_key.json + ENABLE_ERROR_EMAILS: ${ENABLE_ERROR_EMAILS:-false} + IAPI_ENDPOINT: ${IAPI_ENDPOINT:-http://localhost:8089} + KC_SA_CLIENT_ID: ${KEYCLOAK_SA_PYTHON_API_CLIENT_ID:-sa_cvmanager_python_api} + KC_SA_CLIENT_SECRET: ${KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY:-sa-python-api-secret-key} + + ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES:-true} + ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES:-true} + ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES:-false} + + CORS_DOMAIN: ${CORS_DOMAIN:-*} + KEYCLOAK_ENDPOINT: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-cvmanager} + KEYCLOAK_API_CLIENT_ID: ${KEYCLOAK_API_CLIENT_ID:-cvmanager-api} + KEYCLOAK_API_CLIENT_SECRET_KEY: ${KEYCLOAK_API_CLIENT_SECRET_KEY:-keycloak-secret-key} + + CSM_EMAIL_TO_SEND_FROM: ${CSM_EMAIL_TO_SEND_FROM:-} + CSM_EMAILS_TO_SEND_TO: ${CSM_EMAILS_TO_SEND_TO:-} + CSM_TARGET_SMTP_SERVER_ADDRESS: ${CSM_TARGET_SMTP_SERVER_ADDRESS:-} + CSM_TARGET_SMTP_SERVER_PORT: ${CSM_TARGET_SMTP_SERVER_PORT:-} + CSM_TLS_ENABLED: ${CSM_TLS_ENABLED:-} + CSM_AUTH_ENABLED: ${CSM_AUTH_ENABLED:-} + CSM_EMAIL_APP_USERNAME: ${CSM_EMAIL_APP_USERNAME:-} + CSM_EMAIL_APP_PASSWORD: ${CSM_EMAIL_APP_PASSWORD:-} + + ENVIRONMENT_NAME: ${ENVIRONMENT_NAME:-} + LOGS_LINK: ${LOGS_LINK:-} + + TIMEZONE: ${TIMEZONE:-} + LOGGING_LEVEL: ${API_LOGGING_LEVEL:-} + depends_on: + flyway: + condition: service_completed_successfully logging: options: max-size: '10m' @@ -88,26 +83,24 @@ services: context: webapp dockerfile: Dockerfile args: - API_URI: ${API_ENDPOINT} - MAPBOX_TOKEN: ${MAPBOX_TOKEN} - KEYCLOAK_HOST_URL: ${KEYCLOAK_ENDPOINT} - KEYCLOAK_REALM: ${KEYCLOAK_REALM} - KEYCLOAK_CLIENT_ID: ${KEYCLOAK_GUI_CLIENT_ID} - COUNT_MESSAGE_TYPES: ${COUNTS_MSG_TYPES} - VIEWER_MESSAGE_TYPES: ${VIEWER_MSG_TYPES} - DOT_NAME: ${DOT_NAME} - MAPBOX_INIT_LATITUDE: ${MAPBOX_INIT_LATITUDE} - MAPBOX_INIT_LONGITUDE: ${MAPBOX_INIT_LONGITUDE} - MAPBOX_INIT_ZOOM: ${MAPBOX_INIT_ZOOM} - CVIZ_API_SERVER_URL: ${CVIZ_API_SERVER_URL}${INTERSECTION_API_ROUTE_PREFIX:-/} - CVIZ_API_WS_URL: ${CVIZ_API_WS_URL}${INTERSECTION_API_ROUTE_PREFIX:-/} - ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES} - ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES} - ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES} - ENABLE_MOOVE_AI_FEATURES: ${ENABLE_MOOVE_AI_FEATURES} - WEBAPP_THEME_LIGHT: ${WEBAPP_THEME_LIGHT} - WEBAPP_THEME_DARK: ${WEBAPP_THEME_DARK} - ENABLE_HAAS_FEATURES: ${ENABLE_HAAS_FEATURES} + API_URI: ${API_ENDPOINT:-http://localhost:8081} + MAPBOX_TOKEN: ${MAPBOX_TOKEN?:error} + KEYCLOAK_HOST_URL: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-cvmanager} + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_GUI_CLIENT_ID:-cvmanager-gui} + VIEWER_MESSAGE_TYPES: ${VIEWER_MSG_TYPES:-BSM} + DOT_NAME: ${DOT_NAME:-CDOT} + MAPBOX_INIT_LATITUDE: ${MAPBOX_INIT_LATITUDE:-39.7392} + MAPBOX_INIT_LONGITUDE: ${MAPBOX_INIT_LONGITUDE:--104.9903} + MAPBOX_INIT_ZOOM: ${MAPBOX_INIT_ZOOM:-10} + CVIZ_API_SERVER_URL: ${CVIZ_API_SERVER_URL:-http://localhost:8089}${INTERSECTION_API_ROUTE_PREFIX:-/} + CVIZ_API_WS_URL: ${CVIZ_API_WS_URL:-ws://localhost:8089}${INTERSECTION_API_ROUTE_PREFIX:-/} + ENABLE_RSU_FEATURES: ${ENABLE_RSU_FEATURES:-true} + ENABLE_INTERSECTION_FEATURES: ${ENABLE_INTERSECTION_FEATURES:-true} + ENABLE_WZDX_FEATURES: ${ENABLE_WZDX_FEATURES:-false} + ENABLE_HAAS_FEATURES: ${ENABLE_HAAS_FEATURES:-true} + WEBAPP_THEME_LIGHT: ${WEBAPP_THEME_LIGHT:-dark} + WEBAPP_THEME_DARK: ${WEBAPP_THEME_DARK:-dark} image: jpo_cvmanager_webapp:latest restart: always volumes: @@ -117,11 +110,8 @@ services: cvmanager_keycloak: condition: service_healthy required: false - extra_hosts: - ${WEBAPP_DOMAIN}: ${WEBAPP_HOST_IP} - ${KEYCLOAK_DOMAIN}: ${KC_HOST_IP} ports: - - '80:80' + - '${WEBAPP_PORT:-3000}:80' logging: options: max-size: '10m' @@ -130,17 +120,20 @@ services: profiles: - basic - cvmanager_postgres - - intersection_no_api image: postgis/postgis:15-master restart: always ports: - '5432:5432' environment: - POSTGRES_USER: ${PG_DB_USER} - POSTGRES_PASSWORD: ${PG_DB_PASS} + POSTGRES_USER: ${PG_DB_USER:-postgres} + POSTGRES_PASSWORD: ${PG_DB_PASS:-postgres} volumes: - pgdb:/var/lib/postgresql/data - - ./resources/sql_scripts:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 logging: options: max-size: '10m' @@ -150,43 +143,42 @@ services: - basic - keycloak - cvmanager_keycloak - - intersection_no_api build: context: ./resources/keycloak dockerfile: Dockerfile args: - KEYCLOAK_LOGIN_THEME_NAME: ${KEYCLOAK_LOGIN_THEME_NAME}.jar + KEYCLOAK_LOGIN_THEME_NAME: ${KEYCLOAK_LOGIN_THEME_NAME:-sample_theme}.jar image: jpo_cvmanager_keycloak:latest restart: always depends_on: cvmanager_postgres: required: false condition: service_started - - extra_hosts: - ${WEBAPP_DOMAIN}: ${WEBAPP_HOST_IP} - ${KEYCLOAK_DOMAIN}: ${KC_HOST_IP} ports: - '8084:8080' - '9000:9000' environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} - WEBAPP_ORIGIN: ${WEBAPP_ENDPOINT} - KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN} - KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + WEBAPP_ENDPOINT: ${WEBAPP_ENDPOINT:-http://localhost:${WEBAPP_PORT:-3000}} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_HEALTH_ENABLED: 'true' - KC_LOG_LEVEL: ${KC_LOGGING_LEVEL} + KC_LOG_LEVEL: ${KC_LOG_LEVEL:-INFO} KC_DB: postgres - KC_DB_URL: jdbc:postgresql://${PG_DB_HOST}/postgres?currentSchema=keycloak - KC_DB_USERNAME: ${PG_DB_USER} - KC_DB_PASSWORD: ${PG_DB_PASS} - KC_HOSTNAME: ${KEYCLOAK_DOMAIN} - KEYCLOAK_API_CLIENT_SECRET_KEY: ${KEYCLOAK_API_CLIENT_SECRET_KEY} - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - KC_HOSTNAME_STRICT_HTTPS: 'false' - KC_HTTP_ENABLED: 'true' + KC_DB_SCHEMA: keycloak + KC_DB_URL_HOST: cvmanager_postgres + KC_DB_URL_DATABASE: postgres + KC_DB_URL_PORT: 5432 + KC_DB_USERNAME: ${PG_DB_USER:-postgres} + KC_DB_PASSWORD: ${PG_DB_PASS:-postgres} + KC_HOSTNAME: ${KEYCLOAK_ENDPOINT:-http://${DOCKER_HOST_IP:?error}:8084} + KEYCLOAK_API_CLIENT_SECRET_KEY: ${KEYCLOAK_API_CLIENT_SECRET_KEY:-keycloak-secret-key} + KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY: ${KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY:-sa-count-metric-secret-key} + KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY: ${KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY:-sa-python-api-secret-key} + KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY: ${KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY:-sa-firmware-upgrade-runner-secret-key} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} command: - start-dev - --import-realm @@ -200,6 +192,24 @@ services: retries: 3 start_period: 30s + flyway: + profiles: + - basic + - cvmanager_postgres + image: flyway/flyway:10-alpine + command: migrate + volumes: + - ./resources/db/migration:/flyway/sql + - ./resources/db/flyway.toml:/flyway/conf/flyway.toml + environment: + FLYWAY_URL: jdbc:postgresql://cvmanager_postgres:5432/${PG_DB_NAME:-postgres} + FLYWAY_USER: ${PG_DB_USER:-postgres} + FLYWAY_PASSWORD: ${PG_DB_PASS:-postgres} + depends_on: + cvmanager_postgres: + condition: service_healthy + restart: no + volumes: pgdb: driver: local diff --git a/docs/adr/0001-flyway-database-migrations.md b/docs/adr/0001-flyway-database-migrations.md new file mode 100644 index 000000000..76f54e880 --- /dev/null +++ b/docs/adr/0001-flyway-database-migrations.md @@ -0,0 +1,124 @@ +# ADR-0001: Flyway for Automated Database Migrations + +**Date**: 2026-05-22 +**Status**: Accepted +**Deciders**: CDOT CV Platform team + +--- + +## Context + +Database schema changes in jpo-cvmanager were managed through a directory of manually created and manually executed SQL scripts (`resources/sql_scripts/update_scripts/`). This approach had several compounding problems: + +- **No enforced ordering.** Scripts had to be applied in the correct sequence by a human; there was no mechanism to detect or prevent out-of-order execution. +- **No idempotency guarantees.** A script applied twice would fail or corrupt data in most cases. There was no record of which scripts had been applied to which environment. +- **Inconsistent environments.** Dev, test, and production databases could diverge silently when a migration was missed. Diagnosing schema drift required manual comparison. +- **No pipeline integration.** Migrations were not part of CI or the Kubernetes deployment. Applying changes required direct database access and manual steps on every environment. + +The work item called for: version-controlled, repeatable, automated migrations; pipeline integration; at least one non-production environment validation; a standard naming convention; and developer documentation. + +--- + +## Decision + +We adopt **Flyway 10 Community Edition** as the database migration tool for jpo-cvmanager. + +### What Flyway does + +Flyway tracks which migration scripts have been applied to a database in a `flyway_schema_history` table. On each run, it identifies unapplied scripts, applies them in version order, and records the result. If a script fails, further scripts do not run and the job exits with a non-zero code. + +### Key configuration decisions + +#### Sequential integer versioning (`V{N}__description.sql`) + +We use monotonically increasing integers (`V1__`, `V2__`, `V3__`, etc.). This enforces a known, unambiguous application order and keeps migration history human-readable at a glance. When two branches both introduce a migration and both are merged, the resulting version number conflict surfaces as a merge conflict that must be resolved before merging. This forces explicit team coordination on the correct ordering rather than silently allowing migrations to run out of order. + +The baseline is `V1__baseline.sql` as a special case that consolidates the entire pre-Flyway schema history into a single known starting point. All subsequent migrations increment from there: `V2__`, `V3__`, and so on. + +#### `baselineOnMigrate = true` + +Existing production databases already have the schema from years of manual scripts. We cannot re-run `V1__baseline.sql` on them. `baselineOnMigrate` tells Flyway to stamp the baseline version as already applied on first run, then apply only newer migrations. This allows safe adoption without dropping and recreating the schema. + +#### `outOfOrder` disabled + +`outOfOrder` is left at its default (`false`). Migrations must be applied strictly in version order. When two branches both introduce a migration and both are merged, the version number conflict surfaces as a standard merge conflict that must be resolved before the merge completes. This is intentional: a version conflict signals a coordination issue that should be understood and explicitly resolved, not silently bypassed by allowing out-of-order application. + +#### Repeatable migrations (`R__sample_data.sql`) + +Dev-environment seed data is stored in a repeatable migration (`R__` prefix). Flyway re-applies it whenever its checksum changes. The production Docker image excludes all `R__` files via a `COPY migration/V*.sql` glob in the Dockerfile, so seed data never reaches production. + +### Deployment integration + +#### Docker image + +A dedicated Docker image (`cvmanager-flyway`) is built from `flyway/flyway:10-alpine` with the versioned migration scripts and `flyway.toml` baked in. The image is published to GHCR on every merge to `develop` or a `cdot-release*` branch. CI gates publication on a passing `validate_migrations` job, which runs Flyway against a live PostgreSQL 15 container. + +#### Kubernetes + +A Kubernetes `Job` (`cv-manager-flyway.yaml`) runs the migration image before the API starts. Deployment order is: + +1. Apply Postgres and the Flyway Job. +2. Wait: `kubectl wait --for=condition=complete job/cv-manager-flyway-migrate --timeout=120s` +3. Apply the API Deployment. An init container in the API pod polls `flyway_schema_history` and blocks startup until at least one successful migration row exists. + +#### Local development + +A `flyway` service in `docker-compose.yml` runs automatically with the `basic` profile, mounting local migration files and applying them against the dev Postgres container before the API starts. + +#### rsu-info-bridge + +The RSU Info Bridge service (Java/Spring Boot) is a read-only consumer of the CV Manager schema. It does not own or run migrations in production. For integration tests, Flyway is included as a `test`-scoped dependency: a `TestcontainersConfiguration` bean wires Flyway explicitly (Spring Boot 4 removed `FlywayAutoConfiguration`) and runs the parent project's migration scripts against a Testcontainers PostGIS instance via Maven `testResources`. + +--- + +## Alternatives Considered + +### Liquibase + +Liquibase is the other widely adopted migration tool in the Java ecosystem. It supports XML, YAML, JSON, and SQL changelogs, and offers more granular rollback support. We chose Flyway over Liquibase for these reasons: + +- **Simpler mental model.** Flyway's versioned SQL files map directly to how the team was already thinking about migrations. No XML/YAML wrapper is needed. +- **Lighter tooling.** The Flyway Community Docker image is smaller and has no licensing considerations at our scale. +- **Sufficient rollback story.** Neither tool provides automatic DDL rollback on PostgreSQL (transactions around DDL are non-trivial). In practice, rollback means writing a new forward migration, which Flyway handles fine. + +### Alembic (Python) + +The CV Manager API is Python-based. Alembic (SQLAlchemy migrations) was considered to keep migrations in the same language/stack. We rejected this approach: + +- Alembic couples migration execution to the Python service runtime, which is unsuitable for the rsu-info-bridge (Java) and any future services that share the same schema. +- A dedicated migration image that runs as a standalone Kubernetes Job is a cleaner separation of concerns: migrations are not entangled with application startup. +- Flyway's SQL-only approach is language-agnostic and readable by all contributors regardless of their primary language. + +### Continued manual scripts + +Continuing with manual SQL scripts was rejected. The status quo provided no ordering enforcement, no history tracking, and no pipeline integration. The risk of environment drift and missed migrations was the primary driver for this work item. + +--- + +## Consequences + +### Positive + +- **Consistent schema across environments.** Flyway tracks applied migrations and enforces ordering, eliminating the category of bugs caused by missed or out-of-order scripts. +- **Pipeline-gated changes.** Every PR runs `validate_migrations` against a live Postgres container. Migration SQL errors are caught before merge. +- **Auditable history.** The `flyway_schema_history` table provides a timestamped record of every migration applied to every environment. +- **No manual production access required.** The Kubernetes Job applies migrations as part of the normal deployment workflow. + +### Negative / Trade-offs + +- **No automatic rollback.** Flyway Community does not generate rollback scripts. Reverting a migration requires writing a new forward migration. This is a known limitation of SQL-level DDL migration tools on PostgreSQL. +- **Version conflicts require explicit coordination.** When two branches both add a migration, the resulting version number conflict must be resolved before either branch merges. This is a feature, not a bug, but it does require developers to communicate when working on parallel migrations. +- **Baseline version complexity.** New developers must understand `baselineOnMigrate` when standing up a fresh database against an existing environment. This is documented in `resources/db/README.md` but adds onboarding friction. +- **Flyway version pinning.** The project is pinned to Flyway 10. Future major versions may require migration script or configuration changes. The `validate_migrations` CI job provides a safety net for detecting breakage. + +--- + +## References + +- Migration scripts: `resources/db/migration/` +- Flyway configuration: `resources/db/flyway.toml` +- Docker image: `resources/db/Dockerfile` +- Developer guide: `resources/db/README.md` +- Kubernetes Job: `resources/kubernetes/cv-manager-flyway.yaml` +- Kubernetes deployment guide: `resources/kubernetes/README.md` +- CI jobs: `.github/workflows/ci.yml` (`validate_migrations`, `build_flyway_image`) diff --git a/docs/debugging/login_unsuccessful_unknown_error_occurred.png b/docs/debugging/login_unsuccessful_unknown_error_occurred.png new file mode 100644 index 000000000..4b4584516 Binary files /dev/null and b/docs/debugging/login_unsuccessful_unknown_error_occurred.png differ diff --git a/docs/developer_best_practices.md b/docs/developer_best_practices.md new file mode 100644 index 000000000..a1cce80b9 --- /dev/null +++ b/docs/developer_best_practices.md @@ -0,0 +1,96 @@ +# CV Manager Developer Best Practices + +## Managing Fork Synchronization + +The majority of the CV-Manager development is completed on forks. This process enables development within a controlled environment. One major consideration is how often to synchronize with the upstream repository. The current recommended approach is to develop features within a fork, and contribute/push sets of features to the upstream repository (in this case, USDOT). + +### Upstream ahead of current + +When changes exist on the upstream repository which are not present on the current repository, the "Sync Fork" button can be used to bring those changes into the downstream repository. This can show 3 different menus depending on the diff: + +#### No changes present + +Sync Fork: No Changes + +1. No actions necessary, fork is in sync with upstream + +#### Changes present, no conflicts + +Sync Fork: Update branch | Discard N commits + +- Changes can be merged without review +- If you press "Update Branch", GitHub will merge the upstream commits into the current repository immediately, without creating a PR. This is the preferred approach +- If you would like to create a PR instead, please see [Changes present, conflicts](#changes-present-conflicts) +- WARNING: If you press "Discard N commits", all local commits not present on the upstream repository will be removed. This process is instant and without additional confirmation. See the steps below for completing this process safely + + 1. Create a copy of the current branch (henceforth assumed to be develop). The naming convention is "history/2025_q3" for a major release, or "history/2025_12_31" for date-based + 2. Navigate back to the develop branch, hit "Sync fork", and hit "Discard N commits" + 3. Clone the repository. If the repository is already cloned, checkout the develop branch (the one you discarded the commits on) and run the following command (swap out develop if the branch has a different name) + + ```sh + git reset --hard origin/develop + ``` + + 4. Copy the history branch to a new branch (named something like "develop-rebase-2025_12_31") + 5. Rebase develop into your new branch + ``` + git rebase develop + ``` + 6. Resolve merge conflicts + 7. Create a PR to merge the new branch changes into the default branch + +#### Changes present, conflicts + +Sync Fork: Open pull request | Discard N commits + +- Github has detected that the upstream branch cannot be merged into the current branch without conflicts. The PR that it offers to create is from the current branch into the upstream repository, which is the opposite direction of what we want. We want to resolve the conflicts on our fork, then push up the cleaned up changes at a later date. See the next bullet for instructions on how to create a PR from the upstream branch to the current/default branch +- To create a PR from the upstream repo to yours, fill in the following url: + - https://github.com/{your-organization or user}/{repo name}/compare/{default branch}...{upstream org name}:{repo name}:{default branch} + - Example: https://github.com/cdot-cv/jpo-cvmanager/compare/develop...usdot-jpo-ode:jpo-cvmanager:develop +- See the section above for a description of the "Discard N commits" button function + +## Pull Requests + +Pull requests should be kept to a manageable size, able to be reviewed within 1-2 hours. An ideal PR should be under 400 lines of code changed, with an upper limit of 1000 lines of code changed (using the guideline of 500 lines per hour). If a PR exceeds this size, consider breaking it up into multiple smaller PRs. Exceptions can be made for lines which are auto-generated. + +For more information on PR best practices, see [best-practices-for-peer-code-review](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review) + +When creating a pull request, use the provided [pull request template](../pull_request_template.md). This ensures that all necessary information is provided for reviewers. + +### Squash Merge + +All pull requests should be squash merged into develop. This ensures that we have a history of feature additions without the noise of intermediate commits. + +When making a squash merge, both a message and a description are included. The message should be concise and accurate (this is what is seen first when scrolling through previous commits). This should resemble the PR title. +The extended description should describe important details about the feature including the distinct changes involved and the affected services. This should resemble the PR description. + +#### Synchronizing after a squash commit + +When a squash merge or squash commit is executed, multiple previous commits are replaced by a single squash commit. For any branches which still have the non-squished commits, they need to be synchronized. + +Checkout develop and check for pending changes. + +```sh +git checkout develop +git status +``` + +Ensure there are no pending changes. If there are any changes present, commit/stash them on a feature branch. The next command will delete all pending changes and re-set the history of your local develop to the remote develop + +```sh +git reset --hard origin/develop +``` + +The recommended approach for synchronizing feature branches after a squash is a [merge](https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging). This will pull commits from the source branch into the feature branch, _retaining the un-squashed commits_, while presenting all merge conflicts in 1 wave. These additional un-squashed commits will be removed on the next squash merge into develop. + +```sh +git checkout feature-branch +git merge origin/develop +``` + +If removing the un-squashed commits is a priority, consider using a [rebase](https://git-scm.com/book/ms/v2/Git-Branching-Rebasing). This will _remove all of the un-squashed commits_, then go through the new commits and re-apply them to the updated history. Each step can have it's own merge conflicts, which can be extremely burdensome for large offsets, and each of the new commits will be re-applied, which will change their commit hashes. This can be a risky process, so ensure that the feature branch is backed up before proceeding. + +```sh +git checkout feature-branch +git rebase develop +``` diff --git a/docs/pr_screenshots/sync_fork_no_changes.png b/docs/pr_screenshots/sync_fork_no_changes.png new file mode 100644 index 000000000..62ed570ab Binary files /dev/null and b/docs/pr_screenshots/sync_fork_no_changes.png differ diff --git a/docs/pr_screenshots/sync_fork_open_pr.png b/docs/pr_screenshots/sync_fork_open_pr.png new file mode 100644 index 000000000..b34a19225 Binary files /dev/null and b/docs/pr_screenshots/sync_fork_open_pr.png differ diff --git a/docs/pr_screenshots/sync_fork_update_branch.png b/docs/pr_screenshots/sync_fork_update_branch.png new file mode 100644 index 000000000..ea29aa298 Binary files /dev/null and b/docs/pr_screenshots/sync_fork_update_branch.png differ diff --git a/jpo-utils b/jpo-utils index d9298120d..71625a752 160000 --- a/jpo-utils +++ b/jpo-utils @@ -1 +1 @@ -Subproject commit d9298120d603b9ff9ce3a5520ce885c8bd7ec23d +Subproject commit 71625a75254edba7f2af70a7267d7fda70cc4b01 diff --git a/resources/db/Dockerfile b/resources/db/Dockerfile new file mode 100644 index 000000000..4153cdf7f --- /dev/null +++ b/resources/db/Dockerfile @@ -0,0 +1,6 @@ +FROM flyway/flyway:10-alpine + +# Copy only versioned migrations (V prefix). +# R__sample_data.sql is intentionally excluded — it is dev-only seed data. +COPY migration/V*.sql /flyway/sql/ +COPY flyway.toml /flyway/conf/flyway.toml diff --git a/resources/db/README.md b/resources/db/README.md new file mode 100644 index 000000000..221ab76d2 --- /dev/null +++ b/resources/db/README.md @@ -0,0 +1,145 @@ +# Database Migrations + +CV Manager uses [Flyway](https://flywaydb.org/) to manage PostgreSQL schema changes. Migrations run automatically as a Docker Compose service before the API starts. + +**IMPORTANT**: Once a migration has been merged or applied to any shared environment, never rename it or modify its contents. Create a new migration instead. + +## Directory layout + +``` +resources/db/ + migration/ + V1__baseline.sql # Full current schema (all tables, indexes, sequences) + V2__*.sql # First post-baseline migration + V{N}__*.sql # Subsequent versioned migrations (sequential integers) + R__sample_data.sql # Dev seed data -- re-runs when checksum changes + flyway.toml # Shared Flyway configuration + README.md # This file +``` + +## Naming convention + +``` +V{N}__{snake_case_description}.sql +``` + +| Part | Meaning | Example | +|---------------|------------------------------|---------------------------| +| `N` | Next integer in sequence | `3` | +| `description` | Snake-case summary of change | `add_rsu_telemetry_table` | + +Full example: `V3__add_rsu_telemetry_table.sql` + +**Why sequential integers?** Sequential integers enforce a known, unambiguous application order and keep migration history easy to scan. When two branches both add a migration, the version number +conflict surfaces as a merge conflict that must be resolved explicitly. This forces team coordination on the correct ordering rather than silently allowing migrations to run out of order. + +## Creating a new migration + +1. Identify the next version number: check the highest `V{N}` in `resources/db/migration/` and increment by one. +2. Create a file: `resources/db/migration/V{N}__{snake_case_description}.sql` +3. Write forward-only DDL or DML. Flyway Community does not support rollbacks. +4. Write idempotent SQL where practical (`CREATE TABLE IF NOT EXISTS`, `ON CONFLICT DO NOTHING`). +5. Test locally before committing (see below). + +## Running migrations locally + +```bash +# Apply all pending migrations +docker compose run --rm flyway migrate + +# Inspect current migration state +docker compose run --rm flyway info + +# Validate checksums of applied migrations +docker compose run --rm flyway validate +``` + +The `flyway` service in `docker-compose.yml` runs automatically when you `docker compose up` — it completes before the API service starts. + +## Adopting an existing database (baselineOnMigrate) + +The Flyway config sets `baselineOnMigrate = true` and `baselineVersion = 1`. On first run against a database that already has the schema but no Flyway metadata table, +Flyway stamps V1 as applied without re-executing it, then applies any migrations with versions higher than 1. This is how existing non-production and production +environments are adopted without a rebuild. + +## outOfOrder + +`outOfOrder` is disabled. Migrations must be applied in strict version order. If two branches both introduce a migration with the same version number, the conflict surfaces as a merge conflict that +must be resolved before either branch merges. + +## Deprecated scripts + +`resources/deprecated/sql_scripts/update_scripts/` contains the manually executed scripts that this Flyway setup replaces. That directory is kept as historical reference only. Do not add new scripts +there. + +## Schema Reference + +Table descriptions are stored as SQL comments in the database (applied by migration `V2__add_table_comments.sql`) and are visible in psql via `\d+ ` or +`SELECT obj_description('public.
'::regclass)`. The table below summarizes each table for quick reference. + +| Table | Description | +|-----------------------------------------|-------------------------------------------------------------------------------------------------------------| +| `manufacturers` | RSU and OBU manufacturers supported by this deployment. Tested: Commsignia, Kapsch, Yunex. | +| `rsu_models` | RSU hardware models. Linked to a manufacturer; used for display and firmware upgrade identification. | +| `firmware_images` | Known RSU firmware packages. Stores retrieval and install information used by the API. | +| `firmware_upgrade_rules` | Valid firmware upgrade paths. A from_id->to_id row authorizes a direct upgrade; no row blocks it. | +| `rsu_credentials` | SSH credentials for RSU remote access. Referenced by nickname only — never transmitted over the network. | +| `snmp_credentials` | SNMP credentials for message forwarding configuration. Referenced by nickname only. | +| `snmp_protocols` | SNMP protocol versions used by RSUs. Referenced by nickname. | +| `rsus` | All RSUs in this deployment. Each row appears on the CV Manager map. `primary_route` is denormalized here. | +| `rsu_options` | Per-RSU feature flags: `tim_deposit` and `snmp_monitoring`. | +| `ping` | RSU online/offline ping results. Keep to last 24 hours per RSU — a large table degrades map load times. | +| `rsu_health` | RSU health records from SNMP monitoring. Keep recent data only (same guidance as `ping`). | +| `scms_health` | ISS SCMS certificate health per RSU. Polled every 6 hours. Requires an ISS SCMS service agreement. | +| `iss_keys` | ISS SCMS API tokens used by `iss_health_check` to query certificate status. | +| `roles` | User roles. Required rows: `admin`, `operator`, `user`. | +| `users` | Authorized CV Manager users. `keycloak_id` links to Keycloak. `super_user=1` grants cross-org admin access. | +| `organizations` | Deployment organizations. Users and RSUs are scoped to organizations. | +| `user_organization` | Many-to-many user-to-organization assignments with a role per membership. | +| `rsu_organization` | Many-to-many RSU-to-organization assignments. | +| `snmp_msgfwd_type` | Lookup table for SNMP message forwarding types (e.g., RX, TX). | +| `snmp_msgfwd_config` | Active SNMP message forwarding rules per RSU (type, destination IP/port, time window). | +| `email_type` | Lookup table for notification email categories. | +| `user_email_notification` | User subscriptions to notification email types, including frequency settings. | +| `obu_ota_requests` | Over-the-air firmware update requests for OBU devices. | +| `intersections` | Managed signalized intersections used by intersection management features. | +| `intersection_organization` | Many-to-many intersection-to-organization assignments. | +| `rsu_intersection` | Association between RSUs and nearby intersections. | +| `consecutive_firmware_upgrade_failures` | Consecutive firmware upgrade failure counts per RSU, used to enforce retry limits. | +| `max_retry_limit_reached_instances` | Records when an RSU hits the maximum consecutive firmware upgrade failure limit. | + +## Kubernetes deployment + +The Flyway image for Kubernetes is built and pushed to GHCR automatically by CI on every +merge to `develop` or `cdot-release*`. See `resources/kubernetes/README.md` for how to +identify the correct image tag and apply the migration Job. + +**Views** + +| View | Description | +|-------------------------|-------------------------------------------------------------------| +| `rsu_organization_name` | Joins `rsu_organization` with organization names for display use. | + +### Critical data requirements + +- **`roles`** must always contain exactly three rows with names `'admin'`, `'operator'`, and `'user'`. The application depends on these exact strings for permission checks. +- **`ping`** and **`rsu_health`** should be pruned regularly. Retaining more than 24 hours of data per RSU causes noticeable slowdowns when loading the map. +- **`scms_health`** data is only populated if you have an active ISS SCMS API service agreement. + +## Application-derived constraints + +The admin UI enforces several data rules that the original schema did not encode at the database level. Migration `V4__schema_constraints.sql` closes the gaps identified below so integrity holds +regardless of which client writes the data. + +| Table | Constraint | Type | Rationale | +|-----------------|---------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `rsus` | `milepost >= 0` | CHECK | UI regex `/^\d*\.?\d*$/` only allows non-negative values; a negative milepost has no physical meaning. | +| `users` | `first_name NOT NULL`, `last_name NOT NULL` | NOT NULL | Both fields are required on every create and edit form. NULLs are backfilled to `''` during migration. | +| `roles` | `name IN ('admin', 'operator', 'user')` | CHECK | The application recognises exactly these three lowercase role names. An unrecognised role would silently succeed without this constraint. | +| `intersections` | `intersection_number ~ '^[0-9]+$'` | CHECK | UI regex `/^[0-9]+$/` enforces numeric-only NTCIP intersection IDs. | + +### Intentionally app-layer-only + +**Minimum-one-organization** (users, RSUs, intersections must each belong to at least one organization) cannot be expressed as a declarative column or table constraint because it is a cross-row +cardinality rule on a separate join table. It would require a statement-level or row-level trigger. The rule is enforced exclusively in the application layer (`AdminAddUser`, `AdminAddRsu`, +`AdminAddIntersection` form validation). diff --git a/resources/db/azure-pipelines.yml b/resources/db/azure-pipelines.yml new file mode 100644 index 000000000..363288abe --- /dev/null +++ b/resources/db/azure-pipelines.yml @@ -0,0 +1,25 @@ +# Pipeline for creating and pushing artifacts for Flyway migrations + +trigger: + branches: + include: + - develop + paths: + include: + - 'resources/db/*' + +pool: + vmImage: ubuntu-latest + +steps: + - task: CopyFiles@2 + inputs: + SourceFolder: 'resources/db' + Contents: '**' + TargetFolder: '$(Build.ArtifactStagingDirectory)' + + - task: PublishBuildArtifacts@1 + inputs: + PathtoPublish: '$(Build.ArtifactStagingDirectory)' + ArtifactName: 'jpo-cvmanager-flyway' + publishLocation: 'Container' diff --git a/resources/db/flyway.toml b/resources/db/flyway.toml new file mode 100644 index 000000000..1925e311b --- /dev/null +++ b/resources/db/flyway.toml @@ -0,0 +1,6 @@ +[flyway] +locations = ["filesystem:/flyway/sql"] +baselineOnMigrate = true +baselineVersion = "1" +validateMigrationNaming = true +mixed = true diff --git a/resources/db/migration/R__sample_data.sql b/resources/db/migration/R__sample_data.sql new file mode 100644 index 000000000..12f2afd7f --- /dev/null +++ b/resources/db/migration/R__sample_data.sql @@ -0,0 +1,233 @@ +-- R__sample_data.sql +-- Dev seed data for local development and testing only. +-- Do NOT apply to production environments. +-- Flyway re-runs this script whenever its checksum changes. +-- +-- Layout: +-- * Every base table seeded here has at least three rows. +-- * RSUs, intersections, and users are seeded so that EACH of the three +-- organizations has at least three of each, using a "hybrid" model: one +-- shared resource is assigned to all three orgs, and each org additionally +-- gets two org-unique resources (shared core + 2 unique = 3 per org). +-- * IDs are assumed to start at 1 on a fresh database (serial sequences emit +-- 1, 2, 3 ... in insert order). FK references below rely on that ordering. +-- +-- Org membership map (shared core = the first row of each resource): +-- RSUs: Org1 -> 1,2,3 Org2 -> 1,4,5 Org3 -> 1,6,7 +-- Intersections: Org1 -> 1,2,3 Org2 -> 1,4,5 Org3 -> 1,6,7 +-- Users: Org1 -> 1,2,3 Org2 -> 1,4,5 Org3 -> 1,6,7 + +INSERT INTO public.manufacturers(name) + VALUES ('Commsignia'), ('Yunex'), ('Kapsch') + ON CONFLICT (name) DO NOTHING; + +INSERT INTO public.rsu_models(name, supported_radio, manufacturer) + VALUES ('ITS-RS4-M', 'DSRC,C-V2X', 1), ('RSU2X US', 'DSRC,C-V2X', 2), ('RIS-9260', 'C-V2X', 3) + ON CONFLICT (name) DO NOTHING; + +INSERT INTO public.firmware_images(name, model, install_package, version) + VALUES ('y20.0.0', 1, 'install_y20_0_0.tar', 'y20.0.0'), ('y20.1.0', 1, 'install_y20_1_0.tar', 'y20.1.0'), ('k1.0.0', 3, 'install_k1_0_0.tar', 'k1.0.0') + ON CONFLICT (name) DO NOTHING; + +-- Three upgrade rules across the three firmware images. UNIQUE (from_id, to_id) +-- means a third meaningful rule is only possible because firmware_image 3 exists. +INSERT INTO public.firmware_upgrade_rules(from_id, to_id) + VALUES (1, 2), (2, 3), (1, 3) + ON CONFLICT DO NOTHING; + +INSERT INTO public.organizations(name) + VALUES ('Test Org'), ('Test Org 2'), ('Test Org 3') + ON CONFLICT (name) DO NOTHING; + +INSERT INTO public.rsu_credentials(username, password, nickname, owner_organization_id) + VALUES ('username', 'password', 'cred1', 1), + ('username2', 'password2', 'cred2', 2), + ('username3', 'password3', 'cred3', 3) + ON CONFLICT (nickname) DO NOTHING; + +INSERT INTO public.snmp_credentials(username, password, encrypt_password, nickname, owner_organization_id) + VALUES ('username', 'password', 'encryption-pw', 'snmp1', 1), + ('username2', 'password2', 'encryption-pw2', 'snmp2', 2), + ('username3', 'password3', 'encryption-pw3', 'snmp3', 3) + ON CONFLICT (nickname) DO NOTHING; + +INSERT INTO public.snmp_protocols(protocol_code, nickname) + VALUES ('41', 'RSU 4.1'), ('1218', 'NTCIP 1218'), ('42', 'RSU 4.2') + ON CONFLICT (nickname) DO NOTHING; + +-- Seven RSUs. RSU 1 is the shared core (assigned to all three orgs below); RSUs +-- 2-7 are the org-unique pairs. model-2 RSUs have no matching firmware image, so +-- their firmware columns are left NULL (both columns are nullable). +INSERT INTO public.rsus(geography, milepost, ipv4_address, serial_number, iss_scms_id, primary_route, model, credential_id, snmp_credential_id, snmp_protocol_id, firmware_version, target_firmware_version) + VALUES + (ST_GeomFromText('POINT(-105.0135030 39.7405654)'), 1, '10.0.0.11', 'E0001', 'I0001', 'I25', 1, 1, 1, 1, 1, 2), + (ST_GeomFromText('POINT(-104.9877750 39.9818050)'), 2, '10.0.0.12', 'E0002', 'I0002', 'I25', 1, 1, 1, 1, 1, 2), + (ST_GeomFromText('POINT(-105.0908854 39.5880413)'), 3, '10.0.0.13', 'E0003', 'I0003', 'I25', 2, 2, 2, 2, NULL, NULL), + (ST_GeomFromText('POINT(-104.9712000 39.7392000)'), 4, '10.0.0.14', 'E0004', 'I0004', 'I70', 2, 2, 2, 2, NULL, NULL), + (ST_GeomFromText('POINT(-104.8214000 39.7280000)'), 5, '10.0.0.15', 'E0005', 'I0005', 'I70', 3, 3, 3, 3, 3, 3), + (ST_GeomFromText('POINT(-105.2705000 40.0150000)'), 6, '10.0.0.16', 'E0006', 'I0006', 'I70', 3, 3, 3, 3, 3, 3), + (ST_GeomFromText('POINT(-104.8319000 38.8339000)'), 7, '10.0.0.17', 'E0007', 'I0007', 'US36', 1, 1, 1, 1, 1, 2) + ON CONFLICT DO NOTHING; + +-- One rsu_options row per RSU (1:1 extension). A few have tim_deposit = true to +-- populate the partial index idx_rsu_options_tim_deposit. +INSERT INTO public.rsu_options(rsu_id, tim_deposit, snmp_monitoring) + VALUES (1, TRUE, TRUE), (2, FALSE, TRUE), (3, TRUE, TRUE), (4, FALSE, FALSE), + (5, TRUE, FALSE), (6, FALSE, TRUE), (7, TRUE, TRUE) + ON CONFLICT (rsu_id) DO NOTHING; + +INSERT INTO public.roles(name) + VALUES ('admin'), ('operator'), ('user') + ON CONFLICT (name) DO NOTHING; + +-- RSU 1 is shared across all three orgs; each org gets two more unique RSUs, so +-- every org has exactly three RSUs. +INSERT INTO public.rsu_organization(rsu_id, organization_id) + VALUES (1, 1), (2, 1), (3, 1), + (1, 2), (4, 2), (5, 2), + (1, 3), (6, 3), (7, 3) + ON CONFLICT DO NOTHING; + +-- Replace user 1's email with a real address to test GCP OAuth2.0 support. +-- User 1 is the shared core (member of all three orgs); users 2-7 are org-unique. +INSERT INTO public.users(keycloak_id, email, first_name, last_name, created_timestamp, super_user) + VALUES + ('fc3d8729-8526-4aaa-805b-d64bf3b93860'::UUID, 'test@gmail.com', 'Test', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '1'), + ('a1b2c3d4-0000-4aaa-805b-000000000002'::UUID, 'test2@gmail.com', 'Test2', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0'), + ('a1b2c3d4-0000-4aaa-805b-000000000003'::UUID, 'test3@gmail.com', 'Test3', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0'), + ('a1b2c3d4-0000-4aaa-805b-000000000004'::UUID, 'test4@gmail.com', 'Test4', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0'), + ('a1b2c3d4-0000-4aaa-805b-000000000005'::UUID, 'test5@gmail.com', 'Test5', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0'), + ('a1b2c3d4-0000-4aaa-805b-000000000006'::UUID, 'test6@gmail.com', 'Test6', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0'), + ('a1b2c3d4-0000-4aaa-805b-000000000007'::UUID, 'test7@gmail.com', 'Test7', 'User', (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000), '0') + ON CONFLICT (email) DO NOTHING; + +-- User 1 is shared across all three orgs; each org gets two more unique users, so +-- every org has exactly three members. Roles cycle admin/operator/user. +INSERT INTO public.user_organization(user_id, organization_id, role_id) + VALUES (1, 1, 1), (2, 1, 2), (3, 1, 3), + (1, 2, 1), (4, 2, 2), (5, 2, 3), + (1, 3, 1), (6, 3, 2), (7, 3, 3) + ON CONFLICT DO NOTHING; + +INSERT INTO public.snmp_msgfwd_type(name) + VALUES ('rsuDsrcFwd'), ('rsuReceivedMsg'), ('rsuXmitMsgFwding') + ON CONFLICT (name) DO NOTHING; + +INSERT INTO public.snmp_msgfwd_config(rsu_id, msgfwd_type, snmp_index, message_type, dest_ipv4, dest_port, start_datetime, end_datetime, active, security) + VALUES + (1, 1, 1, 'BSM', '10.0.0.80', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '0'), + (1, 1, 2, 'BSM', '10.0.0.81', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '0'), + (1, 1, 3, 'BSM', '10.0.0.82', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '1'), + (2, 2, 1, 'BSM', '10.0.0.80', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '1'), + (2, 2, 2, 'BSM', '10.0.0.81', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '1'), + (2, 3, 1, 'MAP', '10.0.0.80', 44920, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '1'), + (2, 3, 2, 'SPAT', '10.0.0.80', 44910, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '0'), + (3, 1, 1, 'BSM', '10.0.0.80', 46800, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '0') + ON CONFLICT DO NOTHING; + +INSERT INTO public.email_type(email_type, required_role, description, supports_immediate, supports_hourly, supports_daily, supports_weekly, supports_monthly) + VALUES + ('Support Requests', 1, 'Receive support requests from users', true, false, false, false, false), + ('Firmware Upgrade Failures', 2, 'Receive automated firmware upgrade failure emails', true, false, false, false, false), + ('Daily Message Counts', 3, 'Receive automated daily message count emails', false, false, true, false, false), + ('Access Requests', 1, 'Receive organization access requests from users', true, false, false, false, false), + ('Intersection Notification Summary',3, 'Receive automated intersection notification summary emails', true, true, true, true, true), + ('Critical Error Messages', 2, 'Receive automated critical error message emails', true, false, false, false, false) + ON CONFLICT (email_type) DO UPDATE SET + required_role = EXCLUDED.required_role, + description = EXCLUDED.description, + supports_immediate = EXCLUDED.supports_immediate, + supports_hourly = EXCLUDED.supports_hourly, + supports_daily = EXCLUDED.supports_daily, + supports_weekly = EXCLUDED.supports_weekly, + supports_monthly = EXCLUDED.supports_monthly; + +INSERT INTO public.user_email_notification(user_email_notification_id, user_id, email_type_id, immediate, hourly, daily, weekly, monthly) + VALUES + (1, 1, 1, true, false, false, false, false), + (2, 1, 2, true, false, false, false, false), + (3, 1, 3, false, false, true, false, false), + (4, 1, 4, true, false, false, false, false), + (5, 1, 5, true, true, true, true, true), + (6, 1, 6, true, false, false, false, false) + ON CONFLICT DO NOTHING; + +-- Seven intersections. Intersection 1 is the shared core (assigned to all three +-- orgs below); intersections 2-7 are the org-unique pairs. +INSERT INTO public.intersections(intersection_number, ref_pt, intersection_name) + VALUES + (12109, ST_GeomFromText('POINT(-105.0908854 39.5880413)'), 'S Wadsworth & W Columbine Dr'), + (12110, ST_GeomFromText('POINT(-104.9876000 39.7392000)'), 'E Colfax Ave & N Broadway'), + (12111, ST_GeomFromText('POINT(-104.8910000 39.7000000)'), 'S Parker Rd & E Hampden Ave'), + (12112, ST_GeomFromText('POINT(-105.0178000 39.7625000)'), 'Federal Blvd & W 38th Ave'), + (12113, ST_GeomFromText('POINT(-104.9390000 39.6766000)'), 'S Colorado Blvd & E Evans Ave'), + (12114, ST_GeomFromText('POINT(-105.2110000 39.7150000)'), 'US-6 & 6th Ave Pkwy'), + (12115, ST_GeomFromText('POINT(-104.7560000 39.8560000)'), 'E-470 & Pena Blvd') + ON CONFLICT (intersection_number) DO NOTHING; + +-- Intersection 1 is shared across all three orgs; each org gets two more unique +-- intersections, so every org has exactly three. +INSERT INTO public.intersection_organization(intersection_id, organization_id) + VALUES (1, 1), (2, 1), (3, 1), + (1, 2), (4, 2), (5, 2), + (1, 3), (6, 3), (7, 3) + ON CONFLICT DO NOTHING; + +-- A few RSU <-> intersection links (UNIQUE rsu_id, intersection_id). +INSERT INTO public.rsu_intersection(rsu_id, intersection_id) + VALUES (1, 1), (2, 2), (3, 3) + ON CONFLICT DO NOTHING; + +-- Firmware-failure child tables (CASCADE on RSU delete). Three rows each, on +-- distinct RSUs. consecutive_firmware_upgrade_failures has a single-column rsu_id +-- PK; max_retry_limit_reached_instances has a (rsu_id, reached_at) PK and its +-- target_firmware_version references firmware_images. +INSERT INTO public.consecutive_firmware_upgrade_failures(rsu_id, consecutive_failures) + VALUES (5, 2), (6, 1), (7, 3) + ON CONFLICT (rsu_id) DO NOTHING; + +INSERT INTO public.max_retry_limit_reached_instances(rsu_id, reached_at, target_firmware_version) + VALUES + (5, '2026/01/01T00:00:00', 3), + (6, '2026/01/01T00:00:00', 3), + (7, '2026/01/01T00:00:00', 2) + ON CONFLICT DO NOTHING; + +-- Telemetry tables have serial PKs with no natural unique key, so each block is +-- guarded by NOT EXISTS to stay idempotent across checksum re-runs. Seeded for +-- RSU 1 and RSU 2. +INSERT INTO public.ping(timestamp, result, rsu_id) + SELECT ts, res, rid + FROM (VALUES + ('2026/06/01T00:00:00'::timestamp, B'1', 1), + ('2026/06/01T00:05:00'::timestamp, B'1', 1), + ('2026/06/01T00:10:00'::timestamp, B'0', 1), + ('2026/06/01T00:00:00'::timestamp, B'1', 2), + ('2026/06/01T00:05:00'::timestamp, B'0', 2), + ('2026/06/01T00:10:00'::timestamp, B'1', 2) + ) AS seed(ts, res, rid) + WHERE NOT EXISTS (SELECT 1 FROM public.ping); + +INSERT INTO public.rsu_health(timestamp, health, rsu_id) + SELECT ts, hlth, rid + FROM (VALUES + ('2026/06/01T00:00:00'::timestamp, 1, 1), + ('2026/06/01T00:05:00'::timestamp, 1, 1), + ('2026/06/01T00:10:00'::timestamp, 0, 1), + ('2026/06/01T00:00:00'::timestamp, 1, 2), + ('2026/06/01T00:05:00'::timestamp, 0, 2), + ('2026/06/01T00:10:00'::timestamp, 1, 2) + ) AS seed(ts, hlth, rid) + WHERE NOT EXISTS (SELECT 1 FROM public.rsu_health); + +INSERT INTO public.scms_health(timestamp, health, expiration, rsu_id) + SELECT ts, hlth, exp, rid + FROM (VALUES + ('2026/06/01T00:00:00'::timestamp, B'1', '2027/06/01T00:00:00'::timestamp, 1), + ('2026/06/01T00:05:00'::timestamp, B'1', '2027/06/01T00:00:00'::timestamp, 1), + ('2026/06/01T00:10:00'::timestamp, B'0', '2027/06/01T00:00:00'::timestamp, 1), + ('2026/06/01T00:00:00'::timestamp, B'1', '2027/06/01T00:00:00'::timestamp, 2), + ('2026/06/01T00:05:00'::timestamp, B'1', '2027/06/01T00:00:00'::timestamp, 2), + ('2026/06/01T00:10:00'::timestamp, B'0', '2027/06/01T00:00:00'::timestamp, 2) + ) AS seed(ts, hlth, exp, rid) + WHERE NOT EXISTS (SELECT 1 FROM public.scms_health); diff --git a/resources/db/migration/V1__baseline.sql b/resources/db/migration/V1__baseline.sql new file mode 100644 index 000000000..e891c13ba --- /dev/null +++ b/resources/db/migration/V1__baseline.sql @@ -0,0 +1,631 @@ +-- V1__baseline.sql +-- Full current schema for CV Manager PostgreSQL database. +-- Represents the end-state of CVManager_CreateTables.sql + all 19 update_scripts. +-- Existing live databases are stamped at this version via baselineOnMigrate=true +-- and are NOT re-migrated by this script. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE SCHEMA IF NOT EXISTS keycloak; + +CREATE SEQUENCE public.manufacturers_manufacturer_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.manufacturers +( + manufacturer_id integer NOT NULL DEFAULT nextval('manufacturers_manufacturer_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT manufacturers_pkey PRIMARY KEY (manufacturer_id), + CONSTRAINT manufacturers_name UNIQUE (name) +); + +CREATE SEQUENCE public.rsu_models_rsu_model_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_models +( + rsu_model_id integer NOT NULL DEFAULT nextval('rsu_models_rsu_model_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + supported_radio character varying(128) COLLATE pg_catalog.default NOT NULL, + manufacturer integer NOT NULL, + CONSTRAINT rsu_models_pkey PRIMARY KEY (rsu_model_id), + CONSTRAINT rsu_models_name UNIQUE (name), + CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) + REFERENCES public.manufacturers (manufacturer_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.firmware_images_firmware_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.firmware_images +( + firmware_id integer NOT NULL DEFAULT nextval('firmware_images_firmware_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + model integer NOT NULL, + install_package character varying(128) COLLATE pg_catalog.default NOT NULL, + version character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT firmware_images_pkey PRIMARY KEY (firmware_id), + CONSTRAINT firmware_images_name UNIQUE (name), + CONSTRAINT firmware_images_install_package UNIQUE (install_package), + CONSTRAINT firmware_images_version UNIQUE (version), + CONSTRAINT fk_model FOREIGN KEY (model) + REFERENCES public.rsu_models (rsu_model_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.firmware_upgrade_rules_firmware_upgrade_rule_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.firmware_upgrade_rules +( + firmware_upgrade_rule_id integer NOT NULL DEFAULT nextval('firmware_upgrade_rules_firmware_upgrade_rule_id_seq'::regclass), + from_id integer NOT NULL, + to_id integer NOT NULL, + CONSTRAINT firmware_upgrade_rules_pkey PRIMARY KEY (firmware_upgrade_rule_id), + CONSTRAINT fk_from_id FOREIGN KEY (from_id) + REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_to_id FOREIGN KEY (to_id) + REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.organizations_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.organizations +( + organization_id integer NOT NULL DEFAULT nextval('organizations_organization_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + email character varying(128) COLLATE pg_catalog.default, + CONSTRAINT organizations_pkey PRIMARY KEY (organization_id), + CONSTRAINT organizations_name UNIQUE (name) +); + +CREATE SEQUENCE public.rsu_credentials_credential_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_credentials +( + credential_id integer NOT NULL DEFAULT nextval('rsu_credentials_credential_id_seq'::regclass), + username character varying(128) COLLATE pg_catalog.default NOT NULL, + password character varying(128) COLLATE pg_catalog.default NOT NULL, + nickname character varying(128) COLLATE pg_catalog.default NOT NULL, + owner_organization_id integer NOT NULL, + CONSTRAINT rsu_credentials_pkey PRIMARY KEY (credential_id), + CONSTRAINT rsu_credentials_nickname UNIQUE (nickname), + CONSTRAINT fk_rsu_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.snmp_credentials_snmp_credential_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.snmp_credentials +( + snmp_credential_id integer NOT NULL DEFAULT nextval('snmp_credentials_snmp_credential_id_seq'::regclass), + username character varying(128) COLLATE pg_catalog.default NOT NULL, + password character varying(128) COLLATE pg_catalog.default NOT NULL, + encrypt_password character varying(128) COLLATE pg_catalog.default, + nickname character varying(128) COLLATE pg_catalog.default NOT NULL, + owner_organization_id integer NOT NULL, + CONSTRAINT snmp_credentials_pkey PRIMARY KEY (snmp_credential_id), + CONSTRAINT snmp_credentials_nickname UNIQUE (nickname), + CONSTRAINT fk_snmp_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.snmp_protocols_snmp_protocol_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.snmp_protocols +( + snmp_protocol_id integer NOT NULL DEFAULT nextval('snmp_protocols_snmp_protocol_id_seq'::regclass), + protocol_code character varying(128) COLLATE pg_catalog.default NOT NULL, + nickname character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT snmp_protocols_pkey PRIMARY KEY (snmp_protocol_id), + CONSTRAINT snmp_protocols_nickname UNIQUE (nickname) +); + +CREATE SEQUENCE public.rsus_rsu_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsus +( + rsu_id integer NOT NULL DEFAULT nextval('rsus_rsu_id_seq'::regclass), + geography geography NOT NULL, + milepost double precision NOT NULL, + ipv4_address inet NOT NULL, + serial_number character varying(128) COLLATE pg_catalog.default NOT NULL, + iss_scms_id character varying(128) COLLATE pg_catalog.default NOT NULL, + primary_route character varying(128) COLLATE pg_catalog.default NOT NULL, + model integer NOT NULL, + credential_id integer NOT NULL, + snmp_credential_id integer NOT NULL, + snmp_protocol_id integer NOT NULL, + firmware_version integer, + target_firmware_version integer, + CONSTRAINT rsu_pkey PRIMARY KEY (rsu_id), + CONSTRAINT rsu_ipv4_address UNIQUE (ipv4_address), + CONSTRAINT rsu_milepost_primary_route UNIQUE (milepost, primary_route), + CONSTRAINT rsu_serial_number UNIQUE (serial_number), + CONSTRAINT rsu_iss_scms_id UNIQUE (iss_scms_id), + CONSTRAINT fk_model FOREIGN KEY (model) + REFERENCES public.rsu_models (rsu_model_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_credential_id FOREIGN KEY (credential_id) + REFERENCES public.rsu_credentials (credential_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_snmp_credential_id FOREIGN KEY (snmp_credential_id) + REFERENCES public.snmp_credentials (snmp_credential_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_snmp_protocol_id FOREIGN KEY (snmp_protocol_id) + REFERENCES public.snmp_protocols (snmp_protocol_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_firmware_version FOREIGN KEY (firmware_version) + REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_target_firmware_version FOREIGN KEY (target_firmware_version) + REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE TABLE IF NOT EXISTS public.rsu_options +( + rsu_id integer NOT NULL, + tim_deposit boolean NOT NULL DEFAULT FALSE, + snmp_monitoring boolean NOT NULL DEFAULT FALSE, + CONSTRAINT rsu_options_pkey PRIMARY KEY (rsu_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.ping_ping_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.ping +( + ping_id integer NOT NULL DEFAULT nextval('ping_ping_id_seq'::regclass), + timestamp timestamp without time zone NOT NULL, + result bit(1) NOT NULL, + rsu_id integer NOT NULL, + CONSTRAINT ping_pkey PRIMARY KEY (ping_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.roles_role_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.roles +( + role_id integer NOT NULL DEFAULT nextval('roles_role_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT roles_pkey PRIMARY KEY (role_id), + CONSTRAINT roles_name UNIQUE (name) +); + +CREATE SEQUENCE public.users_user_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.users +( + user_id integer NOT NULL DEFAULT nextval('users_user_id_seq'::regclass), + keycloak_id UUID NOT NULL DEFAULT uuid_generate_v4(), + email character varying(128) COLLATE pg_catalog.default NOT NULL, + first_name character varying(128), + last_name character varying(128), + created_timestamp bigint NOT NULL, + super_user bit(1) DEFAULT 0::bit NOT NULL, + CONSTRAINT users_pkey PRIMARY KEY (user_id), + CONSTRAINT users_email UNIQUE (email) +); + +CREATE SEQUENCE public.user_organization_user_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.user_organization +( + user_organization_id integer NOT NULL DEFAULT nextval('user_organization_user_organization_id_seq'::regclass), + user_id integer NOT NULL, + organization_id integer NOT NULL, + role_id integer NOT NULL, + CONSTRAINT user_organization_pkey PRIMARY KEY (user_organization_id), + CONSTRAINT fk_user_id FOREIGN KEY (user_id) + REFERENCES public.users (user_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_role_id FOREIGN KEY (role_id) + REFERENCES public.roles (role_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.rsu_organization_rsu_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_organization +( + rsu_organization_id integer NOT NULL DEFAULT nextval('rsu_organization_rsu_organization_id_seq'::regclass), + rsu_id integer NOT NULL, + organization_id integer NOT NULL, + CONSTRAINT rsu_organization_pkey PRIMARY KEY (rsu_organization_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE VIEW public.rsu_organization_name AS +SELECT ro.rsu_id, org.name +FROM public.rsu_organization AS ro +JOIN public.organizations AS org ON ro.organization_id = org.organization_id; + +CREATE SEQUENCE public.iss_keys_iss_key_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.iss_keys +( + iss_key_id integer NOT NULL DEFAULT nextval('iss_keys_iss_key_id_seq'::regclass), + common_name character varying(128) COLLATE pg_catalog.default NOT NULL, + token character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT iss_keys_pkey PRIMARY KEY (iss_key_id) +); + +CREATE SEQUENCE public.scms_health_scms_health_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.scms_health +( + scms_health_id integer NOT NULL DEFAULT nextval('scms_health_scms_health_id_seq'::regclass), + timestamp timestamp without time zone NOT NULL, + health bit(1) NOT NULL, + expiration timestamp without time zone, + rsu_id integer NOT NULL, + CONSTRAINT scms_health_pkey PRIMARY KEY (scms_health_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.rsu_health_rsu_health_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_health +( + rsu_health_id integer NOT NULL DEFAULT nextval('rsu_health_rsu_health_id_seq'::regclass), + timestamp timestamp without time zone NOT NULL, + health integer NOT NULL, + rsu_id integer NOT NULL, + CONSTRAINT rsu_health_pkey PRIMARY KEY (rsu_health_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.snmp_msgfwd_type_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.snmp_msgfwd_type +( + snmp_msgfwd_type_id integer NOT NULL DEFAULT nextval('snmp_msgfwd_type_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT snmp_msgfwd_type_pkey PRIMARY KEY (snmp_msgfwd_type_id), + CONSTRAINT snmp_msgfwd_type_name UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS public.snmp_msgfwd_config +( + rsu_id integer NOT NULL, + msgfwd_type integer NOT NULL, + snmp_index integer NOT NULL, + message_type character varying(128) COLLATE pg_catalog.default NOT NULL, + dest_ipv4 inet NOT NULL, + dest_port integer NOT NULL, + start_datetime timestamp without time zone NOT NULL, + end_datetime timestamp without time zone NOT NULL, + active bit(1) NOT NULL, + security bit(1) NOT NULL, + CONSTRAINT snmp_msgfwd_config_pkey PRIMARY KEY (rsu_id, msgfwd_type, snmp_index), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_msgfwd_type FOREIGN KEY (msgfwd_type) + REFERENCES public.snmp_msgfwd_type (snmp_msgfwd_type_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.email_type_email_type_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.email_type +( + email_type_id integer NOT NULL DEFAULT nextval('email_type_email_type_id_seq'::regclass), + email_type character varying(128) COLLATE pg_catalog.default NOT NULL, + description character varying(256) COLLATE pg_catalog.default, + supports_immediate boolean DEFAULT true NOT NULL, + supports_hourly boolean DEFAULT false NOT NULL, + supports_daily boolean DEFAULT false NOT NULL, + supports_weekly boolean DEFAULT false NOT NULL, + supports_monthly boolean DEFAULT false NOT NULL, + CONSTRAINT email_type_pkey PRIMARY KEY (email_type_id), + CONSTRAINT email_type_unique UNIQUE (email_type), + CONSTRAINT at_least_one_frequency CHECK (supports_immediate OR supports_hourly OR supports_daily OR supports_weekly OR supports_monthly) +); + +CREATE SEQUENCE public.user_email_notification_user_email_notification_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.user_email_notification +( + user_email_notification_id integer NOT NULL DEFAULT nextval('user_email_notification_user_email_notification_id_seq'::regclass), + user_id integer NOT NULL, + email_type_id integer NOT NULL, + immediate boolean DEFAULT true NOT NULL, + hourly boolean DEFAULT false NOT NULL, + daily boolean DEFAULT false NOT NULL, + weekly boolean DEFAULT false NOT NULL, + monthly boolean DEFAULT false NOT NULL, + CONSTRAINT user_email_notification_pkey PRIMARY KEY (user_email_notification_id), + CONSTRAINT user_email_notification_unique UNIQUE (user_id, email_type_id), + CONSTRAINT at_least_one_subscription CHECK (immediate OR hourly OR daily OR weekly OR monthly), + CONSTRAINT fk_user_id FOREIGN KEY (user_id) + REFERENCES public.users (user_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE CASCADE, + CONSTRAINT fk_email_type_id FOREIGN KEY (email_type_id) + REFERENCES public.email_type (email_type_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE CASCADE +); + +CREATE SEQUENCE public.obu_ota_request_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.obu_ota_requests ( + request_id integer NOT NULL DEFAULT nextval('obu_ota_request_id_seq'::regclass), + obu_sn character varying(128) NOT NULL, + request_datetime timestamp NOT NULL, + origin_ip inet NOT NULL, + obu_firmware_version varchar(128) NOT NULL, + requested_firmware_version varchar(128) NOT NULL, + error_status bit(1) NOT NULL, + error_message varchar(128) NOT NULL, + manufacturer int4 NOT NULL, + CONSTRAINT obu_ota_requests_pkey PRIMARY KEY (request_id), + CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) REFERENCES public.manufacturers(manufacturer_id) +); + +CREATE SEQUENCE public.intersections_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersections +( + intersection_id integer NOT NULL DEFAULT nextval('intersections_intersection_id_seq'::regclass), + intersection_number character varying(128) NOT NULL, + ref_pt GEOGRAPHY(POINT, 4326) NOT NULL, + bbox GEOGRAPHY(POLYGON, 4326), + intersection_name character varying(128), + origin_ip inet, + CONSTRAINT intersection_pkey PRIMARY KEY (intersection_id), + CONSTRAINT intersection_intersection_number UNIQUE (intersection_number) +); + +CREATE SEQUENCE public.intersection_organization_intersection_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersection_organization +( + intersection_organization_id integer NOT NULL DEFAULT nextval('intersection_organization_intersection_organization_id_seq'::regclass), + intersection_id integer NOT NULL, + organization_id integer NOT NULL, + CONSTRAINT intersection_organization_pkey PRIMARY KEY (intersection_organization_id), + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.rsu_intersection_rsu_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_intersection +( + rsu_intersection_id integer NOT NULL DEFAULT nextval('rsu_intersection_rsu_intersection_id_seq'::regclass), + rsu_id integer NOT NULL, + intersection_id integer NOT NULL, + CONSTRAINT rsu_intersection_pkey PRIMARY KEY (rsu_intersection_id), + CONSTRAINT rsu_intersection_unique UNIQUE (rsu_id, intersection_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE TABLE IF NOT EXISTS public.consecutive_firmware_upgrade_failures +( + rsu_id integer NOT NULL, + consecutive_failures integer NOT NULL, + CONSTRAINT consecutive_firmware_upgrade_failures_pkey PRIMARY KEY (rsu_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE TABLE IF NOT EXISTS public.max_retry_limit_reached_instances +( + rsu_id integer NOT NULL, + reached_at timestamp without time zone NOT NULL, + target_firmware_version integer NOT NULL, + CONSTRAINT max_retry_limit_reached_instances_pkey PRIMARY KEY (rsu_id, reached_at), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_target_firmware_version FOREIGN KEY (target_firmware_version) + REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +-- Indexes +CREATE INDEX idx_organizations_name ON public.organizations (name); + +-- RSUs +CREATE INDEX idx_rsu_organization ON public.rsu_organization (organization_id, rsu_id); +CREATE INDEX idx_rsus_ipv4_address ON public.rsus (ipv4_address); +CREATE INDEX idx_rsus_ipv4_rsu_id ON public.rsus (ipv4_address, rsu_id); + +-- Intersections +CREATE INDEX idx_intersections_intersection_number ON public.intersections (intersection_number); +CREATE INDEX idx_intersection_id ON public.intersections (intersection_id); +CREATE INDEX idx_intersection_organization ON public.intersection_organization (organization_id, intersection_id); + +-- Users +CREATE INDEX idx_users_email ON public.users (email); +CREATE INDEX idx_users_user_id ON public.users (user_id); +CREATE INDEX idx_user_organization ON public.user_organization (user_id, organization_id); + +-- SCMS health +CREATE INDEX IF NOT EXISTS idx_scms_health_timestamp ON public.scms_health (timestamp); + +COMMIT; diff --git a/resources/db/migration/V2__add_table_comments.sql b/resources/db/migration/V2__add_table_comments.sql new file mode 100644 index 000000000..b1ebd3e8d --- /dev/null +++ b/resources/db/migration/V2__add_table_comments.sql @@ -0,0 +1,149 @@ +-- V202605211729__add_table_comments.sql +-- Adds COMMENT ON TABLE / COLUMN for all tables and the rsu_organization_name view. +-- Preserves documentation previously maintained only in resources/deprecated/sql_scripts/README.md. +-- +-- Each statement is wrapped in a DO block that silently skips if the object does not exist. +-- This tolerates databases that were baselined at V1 before all update scripts were applied. + +BEGIN; + +-- Firmware / hardware catalog +DO $$ BEGIN COMMENT ON TABLE public.manufacturers IS + 'RSU and OBU manufacturers supported by this deployment. Tested manufacturers: Commsignia, Kapsch, Yunex.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.rsu_models IS + 'RSU hardware models. Each model is linked to a manufacturer and identifies firmware upgrade availability.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.firmware_images IS + 'Known RSU firmware packages. Stores the information the API needs to retrieve and install firmware on an RSU.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.firmware_upgrade_rules IS + 'Valid firmware upgrade paths. A from_id->to_id row means an RSU on from_id firmware can upgrade directly to to_id. Without a matching row the upgrade is blocked to prevent skipping intermediate versions.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Credentials +DO $$ BEGIN COMMENT ON TABLE public.rsu_credentials IS + 'SSH credentials for RSU remote access (reboots, firmware upgrades). Referenced by nickname so credentials are never transmitted over the network.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.snmp_credentials IS + 'SNMP credentials for RSU message forwarding configuration. Referenced by nickname so credentials are never transmitted over the network.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.snmp_protocols IS + 'SNMP protocol versions used by RSUs for message forwarding. Referenced by nickname.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Core RSU data +DO $$ BEGIN COMMENT ON TABLE public.rsus IS + 'All RSUs managed by this deployment. Each row appears on the CV Manager map. primary_route is a denormalized field stored here rather than in a separate table.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON COLUMN public.rsus.primary_route IS + 'Denormalized route name (e.g., I-25, US-36). No separate route table exists.'; +EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.rsu_options IS + 'Per-RSU feature flags. tim_deposit enables TIM message depositing; snmp_monitoring enables SNMP-based health monitoring for this RSU.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Health / status +DO $$ BEGIN COMMENT ON TABLE public.ping IS + 'RSU online/offline ping results. Keep at most 24 hours of records per RSU (or only the most recent record per RSU). Allowing this table to grow large degrades CV Manager map load times. Populated by Zabbix or an automated ping script.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.rsu_health IS + 'RSU health status records collected via SNMP monitoring. Similar to ping — keep recent data only to avoid performance impact on the map.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.scms_health IS + 'ISS SCMS certificate health status per RSU. Populated by polling the ISS SCMS API every 6 hours. Requires an active ISS SCMS service agreement.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.iss_keys IS + 'ISS SCMS API authentication tokens used by the iss_health_check service to query certificate status on behalf of RSUs.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Users and organizations +DO $$ BEGIN COMMENT ON TABLE public.roles IS + 'User roles assignable within an organization. Three rows are required by the application: admin, operator, and user. Do not rename or remove these rows.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON COLUMN public.roles.name IS + 'Required values: ''admin'', ''operator'', ''user''. The application depends on these exact role names.'; +EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.users IS + 'Users authorized to access the CV Manager. Keycloak is the identity provider; keycloak_id links to the Keycloak user record. Users with super_user=1 can access the admin panel across all organizations.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON COLUMN public.users.super_user IS + '1 = user can access the admin panel and manage resources across all organizations. 0 = normal user.'; +EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.organizations IS + 'Deployment organizations. Users and RSUs are scoped to organizations; a user can only access RSUs within their own organizations.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.user_organization IS + 'Many-to-many assignment of users to organizations, with a role per membership.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.rsu_organization IS + 'Many-to-many assignment of RSUs to organizations.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- SNMP message forwarding +DO $$ BEGIN COMMENT ON TABLE public.snmp_msgfwd_type IS + 'Lookup table for SNMP message forwarding types (e.g., RX, TX).'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.snmp_msgfwd_config IS + 'Active SNMP message forwarding rules per RSU. Defines which message types to forward, to what destination IP/port, and over what time window.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Email notifications +DO $$ BEGIN COMMENT ON TABLE public.email_type IS + 'Lookup table for notification email categories available for user subscription.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.user_email_notification IS + 'User subscriptions to email notification types, including per-subscription frequency settings.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- OBU +DO $$ BEGIN COMMENT ON TABLE public.obu_ota_requests IS + 'Over-the-air firmware update requests for OBU (On-Board Unit) devices.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Intersections +DO $$ BEGIN COMMENT ON TABLE public.intersections IS + 'Managed signalized intersections used by the intersection management features.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.intersection_organization IS + 'Many-to-many assignment of intersections to organizations.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.rsu_intersection IS + 'Association between RSUs and nearby intersections.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- Firmware retry tracking +DO $$ BEGIN COMMENT ON TABLE public.consecutive_firmware_upgrade_failures IS + 'Tracks consecutive firmware upgrade failure counts per RSU, used to enforce retry limits before escalating.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +DO $$ BEGIN COMMENT ON TABLE public.max_retry_limit_reached_instances IS + 'Records instances where an RSU has reached the configured maximum consecutive firmware upgrade failure limit.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +-- View +DO $$ BEGIN COMMENT ON VIEW public.rsu_organization_name IS + 'Convenience view joining rsu_organization with organization names for display in the CV Manager UI.'; +EXCEPTION WHEN undefined_table THEN NULL; END $$; + +COMMIT; diff --git a/resources/db/migration/V3__add_email_type_reqired_role.sql b/resources/db/migration/V3__add_email_type_reqired_role.sql new file mode 100644 index 000000000..58b2396d8 --- /dev/null +++ b/resources/db/migration/V3__add_email_type_reqired_role.sql @@ -0,0 +1,58 @@ +-- V3__add_email_type_required_role.sql +-- Adds a required_role column to public.email_type, linking each notification +-- email category to the minimum role a user must hold (within their organization) +-- to receive that email type. +-- +-- Steps performed: +-- 1. Add required_role (integer, nullable) with a FK to public.roles(role_id). +-- 2. Backfill all existing rows with a sensible default (role_id 1 = ADMIN) +-- so the subsequent NOT NULL constraint can be applied safely. +-- 3. Assign the correct role per email_type: +-- - ADMIN (1): Support Requests, Access Requests +-- - OPERATOR (2): Firmware Upgrade Failures, Critical Error Messages +-- - USER (3): Daily Message Counts, Intersection Notification Summary +-- 4. Set the column NOT NULL now that every row has a value. +-- +-- The column is intentionally added as nullable first (step 1) to allow a smooth +-- transition on databases that already contain email_type rows, avoiding a +-- constraint violation before the backfill (step 2-3) has run. + +BEGIN; + +-- Update public.email_type table definition +-- omit NOT NULL constraint on required_role for now to allow for smooth transition, will set to NOT NULL after backfilling data +ALTER TABLE public.email_type +ADD COLUMN IF NOT EXISTS required_role integer; + +-- Add foreign key constraint +ALTER TABLE public.email_type +ADD CONSTRAINT fk_role_id FOREIGN KEY (required_role) + REFERENCES public.roles (role_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION; + +-- Set default value for all existing entries to role_id 1 (ADMIN) +UPDATE public.email_type +SET required_role = 1 +WHERE required_role IS NULL; + +-- ADMIN roles +UPDATE public.email_type +SET required_role = 1 +WHERE email_type IN ('Support Requests', 'Access Requests'); + +-- OPERATOR roles +UPDATE public.email_type +SET required_role = 2 +WHERE email_type IN ('Firmware Upgrade Failures', 'Critical Error Messages'); + +-- USER roles +UPDATE public.email_type +SET required_role = 3 +WHERE email_type IN ('Daily Message Counts', 'Intersection Notification Summary'); + +-- Make the column NOT NULL after setting all values +ALTER TABLE public.email_type +ALTER COLUMN required_role SET NOT NULL; + +COMMIT; \ No newline at end of file diff --git a/resources/db/migration/V4__schema_constraints.sql b/resources/db/migration/V4__schema_constraints.sql new file mode 100644 index 000000000..d7793919c --- /dev/null +++ b/resources/db/migration/V4__schema_constraints.sql @@ -0,0 +1,370 @@ +-- ============================================================ +-- users +-- ============================================================ + +-- Keycloak is the identity provider. Every user lookup, update, and delete +-- filters by keycloak_id. Without a UNIQUE constraint the column had no +-- uniqueness enforcement and no index, making those operations sequential +-- scans and allowing duplicate keycloak_id values to be inserted. +ALTER TABLE public.users + ADD CONSTRAINT users_keycloak_id UNIQUE (keycloak_id); + +-- ============================================================ +-- firmware_upgrade_rules +-- ============================================================ + +-- An upgrade path from firmware version A to version B should be defined +-- only once. Duplicate rows cause findFirstByFrom_Id to return ambiguous +-- results and make the upgrade graph inconsistent. Duplicates are removed +-- before the constraint is added; the count is reported so operators can +-- audit unexpected data loss. +DO $$ +DECLARE removed_count integer; +BEGIN + WITH duplicates AS ( + SELECT firmware_upgrade_rule_id, + ROW_NUMBER() OVER ( + PARTITION BY from_id, to_id + ORDER BY firmware_upgrade_rule_id + ) AS rn + FROM public.firmware_upgrade_rules + ) + DELETE FROM public.firmware_upgrade_rules + WHERE firmware_upgrade_rule_id IN ( + SELECT firmware_upgrade_rule_id FROM duplicates WHERE rn > 1 + ); + GET DIAGNOSTICS removed_count = ROW_COUNT; + RAISE NOTICE 'firmware_upgrade_rules: removed % duplicate row(s) before adding UNIQUE constraint', removed_count; +END $$; + +ALTER TABLE public.firmware_upgrade_rules + ADD CONSTRAINT firmware_upgrade_rules_from_to_unique UNIQUE (from_id, to_id); + +-- ============================================================ +-- rsu_options +-- ============================================================ + +-- rsu_options is a 1:1 structural extension of the rsus row — each RSU has +-- exactly one options row. An orphaned rsu_options row after the parent RSU +-- is deleted has no operational meaning and blocks re-insertion of an RSU +-- with the same rsu_id. CASCADE removes the child automatically when the +-- parent RSU is deleted. +ALTER TABLE public.rsu_options + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- ============================================================ +-- consecutive_firmware_upgrade_failures +-- ============================================================ + +-- 1:1 structural extension of rsus. Same rationale as rsu_options above: +-- the row has no meaning without its parent RSU, and without CASCADE a +-- pending delete is blocked by the child row. +ALTER TABLE public.consecutive_firmware_upgrade_failures + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- ============================================================ +-- snmp_msgfwd_config +-- ============================================================ + +-- SNMP forwarding config rows are RSU-specific. There is no meaningful +-- forwarding configuration without an owning RSU, and without CASCADE a +-- delete of an RSU that has active forwarding entries is blocked entirely. +ALTER TABLE public.snmp_msgfwd_config + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- ============================================================ +-- max_retry_limit_reached_instances +-- ============================================================ + +-- Retry exhaustion records are tied to a specific RSU. The composite primary +-- key includes rsu_id, so a row cannot be reassigned to another RSU; CASCADE +-- is the only meaningful behaviour on RSU deletion. +ALTER TABLE public.max_retry_limit_reached_instances + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- Note: telemetry tables (ping, rsu_health, scms_health) are intentionally +-- left as RESTRICT. These are high-volume time-series tables; RSU deletion +-- should require explicit data pruning before the parent row can be removed, +-- to prevent accidental bulk data loss. + +-- ============================================================ +-- rsu_organization +-- ============================================================ + +-- An RSU can only be assigned to a given organization once. Duplicates are +-- removed before the constraint is added; the count is reported so operators +-- can audit unexpected data loss. +DO $$ +DECLARE removed_count integer; +BEGIN + WITH duplicates AS ( + SELECT rsu_organization_id, + ROW_NUMBER() OVER ( + PARTITION BY rsu_id, organization_id + ORDER BY rsu_organization_id + ) AS rn + FROM public.rsu_organization + ) + DELETE FROM public.rsu_organization + WHERE rsu_organization_id IN ( + SELECT rsu_organization_id FROM duplicates WHERE rn > 1 + ); + GET DIAGNOSTICS removed_count = ROW_COUNT; + RAISE NOTICE 'rsu_organization: removed % duplicate row(s) before adding UNIQUE constraint', removed_count; +END $$; + +ALTER TABLE public.rsu_organization + ADD CONSTRAINT rsu_organization_unique UNIQUE (rsu_id, organization_id); + +-- An RSU may belong to multiple organizations (shared-jurisdiction model, +-- e.g. a Region 1 RSU is also visible to CDOT). CASCADE on rsu_id is safe: +-- deleting an RSU legitimately removes all of its organization memberships. +ALTER TABLE public.rsu_organization + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- organization_id is RESTRICT, not CASCADE: the minimum-one-organization rule +-- (every RSU must remain visible to at least one organization) is enforced in +-- the application layer only. admin_org.delete_org_authorized refuses to +-- delete an organization that would orphan an RSU (check_orphan_rsus) and then +-- removes the membership rows explicitly. A CASCADE here would let any other +-- code path bypass that orphan check at the database level. +ALTER TABLE public.rsu_organization + DROP CONSTRAINT IF EXISTS fk_organization_id, + ADD CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) + ON UPDATE NO ACTION + ON DELETE RESTRICT; + +-- ============================================================ +-- rsu_intersection +-- ============================================================ + +-- UNIQUE (rsu_id, intersection_id) is already enforced in the baseline schema. +-- A junction row linking an RSU to an intersection has no meaning once either +-- parent is deleted. CASCADE on both FKs prevents orphaned rows and allows +-- RSU or intersection deletion without requiring manual cleanup first. +ALTER TABLE public.rsu_intersection + DROP CONSTRAINT IF EXISTS fk_rsu_id, + ADD CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +ALTER TABLE public.rsu_intersection + DROP CONSTRAINT IF EXISTS fk_intersection_id, + ADD CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- ============================================================ +-- user_organization +-- ============================================================ + +-- A user can only be assigned to a given organization once (with one role). +DO $$ +DECLARE removed_count integer; +BEGIN + WITH duplicates AS ( + SELECT user_organization_id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, organization_id + ORDER BY user_organization_id + ) AS rn + FROM public.user_organization + ) + DELETE FROM public.user_organization + WHERE user_organization_id IN ( + SELECT user_organization_id FROM duplicates WHERE rn > 1 + ); + GET DIAGNOSTICS removed_count = ROW_COUNT; + RAISE NOTICE 'user_organization: removed % duplicate row(s) before adding UNIQUE constraint', removed_count; +END $$; + +ALTER TABLE public.user_organization + ADD CONSTRAINT user_organization_unique UNIQUE (user_id, organization_id); + +-- CASCADE on user_id: the Keycloak custom user provider's removeUser operation +-- deletes directly from public.users without first removing user_organization +-- rows. Without CASCADE the delete fails for any user that has organization +-- memberships. user_email_notification already uses CASCADE on user_id for the +-- same reason; this aligns user_organization with that existing pattern. +ALTER TABLE public.user_organization + DROP CONSTRAINT IF EXISTS fk_user_id, + ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) + REFERENCES public.users (user_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- organization_id is RESTRICT, not CASCADE: a user may belong to multiple +-- organizations, and the minimum-one-organization rule is enforced in the +-- application layer only. admin_org.delete_org_authorized refuses to delete +-- an organization that would orphan a user (check_orphan_users) and then +-- removes the membership rows explicitly. A CASCADE here would let any other +-- code path bypass that orphan check at the database level. +-- +-- role_id is deliberately left RESTRICT: roles are reference data that are +-- never deleted, and the RESTRICT FK actively prevents a role still assigned +-- to any user from being removed. +ALTER TABLE public.user_organization + DROP CONSTRAINT IF EXISTS fk_organization_id, + ADD CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) + ON UPDATE NO ACTION + ON DELETE RESTRICT; + +-- ============================================================ +-- intersection_organization +-- ============================================================ + +-- An intersection can only be assigned to a given organization once. +DO $$ +DECLARE removed_count integer; +BEGIN + WITH duplicates AS ( + SELECT intersection_organization_id, + ROW_NUMBER() OVER ( + PARTITION BY intersection_id, organization_id + ORDER BY intersection_organization_id + ) AS rn + FROM public.intersection_organization + ) + DELETE FROM public.intersection_organization + WHERE intersection_organization_id IN ( + SELECT intersection_organization_id FROM duplicates WHERE rn > 1 + ); + GET DIAGNOSTICS removed_count = ROW_COUNT; + RAISE NOTICE 'intersection_organization: removed % duplicate row(s) before adding UNIQUE constraint', removed_count; +END $$; + +ALTER TABLE public.intersection_organization + ADD CONSTRAINT intersection_organization_unique UNIQUE (intersection_id, organization_id); + +-- An intersection may belong to multiple organizations. CASCADE on +-- intersection_id is safe: deleting an intersection legitimately removes all +-- of its organization memberships. +ALTER TABLE public.intersection_organization + DROP CONSTRAINT IF EXISTS fk_intersection_id, + ADD CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) + ON UPDATE NO ACTION + ON DELETE CASCADE; + +-- organization_id is RESTRICT, not CASCADE: the minimum-one-organization rule +-- is enforced in the application layer only. admin_org.delete_org_authorized +-- refuses to delete an organization that would orphan an intersection +-- (check_orphan_intersections) and then removes the membership rows +-- explicitly. A CASCADE here would let any other code path bypass that orphan +-- check at the database level. +ALTER TABLE public.intersection_organization + DROP CONSTRAINT IF EXISTS fk_organization_id, + ADD CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) + ON UPDATE NO ACTION + ON DELETE RESTRICT; + +-- ============================================================ +-- rsus.milepost non-negative +-- ============================================================ + +-- The admin UI rejects negative milepost values (regex /^\d*\.?\d*$/ allows +-- only digits and a decimal point, no leading minus). Without a DB constraint +-- any API client or direct INSERT could store a negative value, which has no +-- physical meaning. Abort the migration if bad data exists so an operator can +-- correct it before the constraint is applied. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM public.rsus WHERE milepost < 0) THEN + RAISE EXCEPTION 'rsus: one or more rows have milepost < 0 — correct the data before applying this migration'; + END IF; +END $$; + +ALTER TABLE public.rsus + ADD CONSTRAINT rsus_milepost_non_negative CHECK (milepost >= 0); + +-- ============================================================ +-- users.first_name / last_name NOT NULL +-- ============================================================ + +-- The admin UI requires both fields on every create/edit form. The baseline +-- schema left them nullable, so direct inserts or legacy data could produce +-- rows the UI would never generate. Backfill NULL to empty string (non- +-- destructive) before tightening the column; the frontend continues to enforce +-- non-empty values on write. +DO $$ +DECLARE null_count integer; +BEGIN + UPDATE public.users SET first_name = '' WHERE first_name IS NULL; + GET DIAGNOSTICS null_count = ROW_COUNT; + RAISE NOTICE 'users: backfilled % NULL first_name value(s) to empty string', null_count; + + UPDATE public.users SET last_name = '' WHERE last_name IS NULL; + GET DIAGNOSTICS null_count = ROW_COUNT; + RAISE NOTICE 'users: backfilled % NULL last_name value(s) to empty string', null_count; +END $$; + +ALTER TABLE public.users + ALTER COLUMN first_name SET NOT NULL, + ALTER COLUMN last_name SET NOT NULL; + +-- ============================================================ +-- roles.name allowed values +-- ============================================================ + +-- The application recognises exactly three role names: admin, operator, user +-- (lowercase — see R__sample_data.sql and auth-api parseRole). The baseline +-- schema used an open varchar with only a UNIQUE constraint, so a direct +-- INSERT of an unrecognised role name would silently succeed. Abort the +-- migration if any unexpected role name is present. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.roles + WHERE name NOT IN ('admin', 'operator', 'user') + ) THEN + RAISE EXCEPTION 'roles: unexpected role name found — only admin, operator, user are allowed'; + END IF; +END $$; + +ALTER TABLE public.roles + ADD CONSTRAINT roles_name_allowed CHECK (name IN ('admin', 'operator', 'user')); + +-- ============================================================ +-- intersections.intersection_number digits-only +-- ============================================================ + +-- The admin UI enforces a digits-only regex (/^[0-9]+$/) for intersection +-- numbers, matching the numeric NTCIP intersection IDs used in the field. +-- Abort the migration if any row would violate the constraint. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM public.intersections + WHERE intersection_number !~ '^[0-9]+$' + ) THEN + RAISE EXCEPTION 'intersections: one or more rows have a non-numeric intersection_number — correct the data before applying this migration'; + END IF; +END $$; + +ALTER TABLE public.intersections + ADD CONSTRAINT intersection_number_numeric CHECK (intersection_number ~ '^[0-9]+$'); diff --git a/resources/db/migration/V5__schema_indexes.sql b/resources/db/migration/V5__schema_indexes.sql new file mode 100644 index 000000000..48e017e30 --- /dev/null +++ b/resources/db/migration/V5__schema_indexes.sql @@ -0,0 +1,191 @@ +-- ============================================================ +-- Drop redundant and unused indexes +-- ============================================================ + +-- PostgreSQL automatically creates an index to enforce each PRIMARY KEY and +-- UNIQUE constraint. Explicit indexes on those same columns duplicate the +-- implicit index, consume extra storage, and add write overhead on every +-- INSERT/UPDATE/DELETE for zero read benefit. The query planner uses the +-- constraint-backed index with identical plans. + +-- Redundant with users_pkey (PRIMARY KEY constraint) +DROP INDEX IF EXISTS public.idx_users_user_id; + +-- Redundant with users_email UNIQUE constraint +DROP INDEX IF EXISTS public.idx_users_email; + +-- Redundant with intersection_pkey (PRIMARY KEY constraint) +DROP INDEX IF EXISTS public.idx_intersection_id; + +-- Redundant with intersection_intersection_number UNIQUE constraint +DROP INDEX IF EXISTS public.idx_intersections_intersection_number; + +-- Redundant with rsu_ipv4_address UNIQUE constraint +DROP INDEX IF EXISTS public.idx_rsus_ipv4_address; + +-- Redundant with organizations_name UNIQUE constraint +DROP INDEX IF EXISTS public.idx_organizations_name; + +-- Redundant: ipv4_address uniquely identifies one row, so rsu_id is always +-- determined by the first column lookup. The UNIQUE constraint index on +-- ipv4_address alone serves all known query patterns against rsus. +DROP INDEX IF EXISTS public.idx_rsus_ipv4_rsu_id; + +-- Made redundant by the user_organization_unique UNIQUE constraint added in V4. +-- Both cover (user_id, organization_id) in the same order. +DROP INDEX IF EXISTS public.idx_user_organization; + +-- Unused: no query filters scms_health by timestamp without also filtering by +-- rsu_id. Replaced below by the composite (rsu_id, timestamp DESC) index. +DROP INDEX IF EXISTS public.idx_scms_health_timestamp; + +-- ============================================================ +-- ping +-- ============================================================ + +-- rsu_id is a FK column — PostgreSQL does not create indexes on FK columns +-- automatically. Without this index every per-RSU ping query (WHERE rsu_id = ?) +-- is a full sequential scan of the entire ping table. The trailing timestamp DESC +-- column covers ORDER BY timestamp DESC result sets and pruning queries that +-- filter by rsu_id before deleting old rows. +CREATE INDEX idx_ping_rsu_id_timestamp + ON public.ping (rsu_id, timestamp DESC); + +COMMENT ON INDEX public.idx_ping_rsu_id_timestamp IS + 'Covers per-RSU ping lookups (WHERE rsu_id = ?) and timestamp-ordered results. ' + 'rsu_id is a FK column — PostgreSQL does not index FK columns automatically. ' + 'Without this index every per-RSU map load query is a full sequential scan. ' + 'Also accelerates pruning queries that filter by rsu_id before deleting old rows.'; + +-- ============================================================ +-- rsu_health +-- ============================================================ + +-- Same rationale as idx_ping_rsu_id_timestamp above. rsu_health mirrors the +-- ping table in growth pattern and query shape. +CREATE INDEX idx_rsu_health_rsu_id_timestamp + ON public.rsu_health (rsu_id, timestamp DESC); + +COMMENT ON INDEX public.idx_rsu_health_rsu_id_timestamp IS + 'Same rationale as idx_ping_rsu_id_timestamp. ' + 'rsu_health mirrors the ping table in growth pattern and query shape.'; + +-- ============================================================ +-- scms_health +-- ============================================================ + +-- Replaces the dropped idx_scms_health_timestamp (single-column, unused). +-- The leading rsu_id column supports per-RSU certificate health lookups. +-- The trailing timestamp DESC column allows PostgreSQL to satisfy +-- ROW_NUMBER() OVER (PARTITION BY rsu_id ORDER BY timestamp DESC) by walking +-- the index in partition order rather than sorting in memory. +CREATE INDEX idx_scms_health_rsu_id_timestamp + ON public.scms_health (rsu_id, timestamp DESC); + +COMMENT ON INDEX public.idx_scms_health_rsu_id_timestamp IS + 'Replaces the dropped idx_scms_health_timestamp (single-column, unused). ' + 'The leading rsu_id column supports per-RSU certificate health lookups. ' + 'The trailing timestamp DESC column allows PostgreSQL to satisfy ' + 'ROW_NUMBER() OVER (PARTITION BY rsu_id ORDER BY timestamp DESC) ' + 'by walking the index in partition order rather than sorting in memory.'; + +-- ============================================================ +-- user_organization +-- ============================================================ + +-- The UNIQUE constraint (user_id, organization_id) added in V4 is user-first +-- and supports lookups that start from the user side. UserOrganizationRepository +-- also has queries that start from the organization side (findByOrganization_Name, +-- findByUserAndOrganization_Name). Without this index those queries require a full +-- sequential scan of user_organization filtered via a join to organizations. +CREATE INDEX idx_user_organization_organization_id + ON public.user_organization (organization_id); + +COMMENT ON INDEX public.idx_user_organization_organization_id IS + 'Supports org-first lookups in UserOrganizationRepository: findByOrganization_Name ' + 'and findByUserAndOrganization_Name. The UNIQUE constraint (user_id, organization_id) ' + 'is user-first and does not cover these query patterns.'; + +-- ============================================================ +-- user_email_notification +-- ============================================================ + +-- All three bulk notification recipient queries in UserEmailNotificationRepository +-- filter by email_type_id as the leading predicate. Without this index each query +-- is a full sequential scan of the entire notification table. +CREATE INDEX idx_user_email_notification_email_type_id + ON public.user_email_notification (email_type_id); + +COMMENT ON INDEX public.idx_user_email_notification_email_type_id IS + 'Supports all three bulk notification recipient queries in UserEmailNotificationRepository, ' + 'each of which filters by email_type_id as the leading predicate. FK column; ' + 'PostgreSQL does not create indexes on FK columns automatically.'; + +-- ============================================================ +-- firmware_upgrade_rules +-- ============================================================ + +-- FirmwareUpgradeRuleRepository.findFirstByFrom_Id resolves the allowed upgrade +-- path for a given firmware version. Without this index every upgrade path +-- resolution is a full sequential scan of firmware_upgrade_rules. +CREATE INDEX idx_firmware_upgrade_rules_from_id + ON public.firmware_upgrade_rules (from_id); + +COMMENT ON INDEX public.idx_firmware_upgrade_rules_from_id IS + 'Supports FirmwareUpgradeRuleRepository.findFirstByFrom_Id, which resolves the ' + 'allowed upgrade path for a given firmware version. FK column without index ' + 'causes a full sequential scan on every upgrade path resolution.'; + +-- ============================================================ +-- rsu_options +-- ============================================================ + +-- Partial index covering only rows where tim_deposit = true. +-- RsuRepository.findByRsuOptionTimDepositIsTrue (rsu-info-bridge) enumerates all +-- TIM-deposit-enabled RSUs. The partial index is deliberately small: only RSUs +-- with the flag set are indexed, which matches the query predicate exactly. +CREATE INDEX idx_rsu_options_tim_deposit + ON public.rsu_options (rsu_id) + WHERE tim_deposit = true; + +COMMENT ON INDEX public.idx_rsu_options_tim_deposit IS + 'Partial index covering only rows where tim_deposit = true. ' + 'Supports rsu-info-bridge RsuRepository.findByRsuOptionTimDepositIsTrue, which ' + 'enumerates all TIM-deposit-enabled RSUs. The partial index is deliberately ' + 'small: only RSUs with the flag set are indexed.'; + +-- ============================================================ +-- rsus +-- ============================================================ + +-- RsuRepository executes SELECT DISTINCT primary_route FROM rsus ORDER BY +-- primary_route ASC to populate the primary route dropdown. Without this index +-- the query requires a full table scan with an in-memory sort. With the index +-- PostgreSQL walks it in order and returns distinct values directly. +CREATE INDEX idx_rsus_primary_route + ON public.rsus (primary_route); + +COMMENT ON INDEX public.idx_rsus_primary_route IS + 'Supports SELECT DISTINCT primary_route FROM rsus ORDER BY primary_route ASC ' + 'used by RsuRepository to populate the primary route dropdown. Without this ' + 'index the query is a full table scan with an in-memory sort. With the index ' + 'PostgreSQL can walk it in order and return distinct values directly.'; + +-- ============================================================ +-- rsu_intersection +-- ============================================================ + +-- The UNIQUE constraint (rsu_id, intersection_id) is rsu-first and supports +-- RSU-first lookups. RsuIntersectionRepository also has queries and DELETE +-- operations that start from the intersection side (by intersection_number, +-- which resolves to intersection_id). Without this index those operations scan +-- all rows in rsu_intersection for every intersection-based lookup or deletion. +CREATE INDEX idx_rsu_intersection_intersection_id + ON public.rsu_intersection (intersection_id); + +COMMENT ON INDEX public.idx_rsu_intersection_intersection_id IS + 'Supports intersection-first queries and DELETE operations in ' + 'RsuIntersectionRepository (e.g. deleteByIntersection_IntersectionNumber, ' + 'DELETE WHERE intersection.intersectionNumber = ? AND rsu.ipv4Address IN (...)). ' + 'The UNIQUE constraint (rsu_id, intersection_id) is rsu-first and does not ' + 'cover these patterns. FK column; PostgreSQL does not index FK columns automatically.'; diff --git a/resources/sql_scripts/CVManager_CreateTables.sql b/resources/deprecated/sql_scripts/CVManager_CreateTables.sql similarity index 87% rename from resources/sql_scripts/CVManager_CreateTables.sql rename to resources/deprecated/sql_scripts/CVManager_CreateTables.sql index bec7cc93a..db563ac3a 100644 --- a/resources/sql_scripts/CVManager_CreateTables.sql +++ b/resources/deprecated/sql_scripts/CVManager_CreateTables.sql @@ -2,6 +2,8 @@ CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE SCHEMA IF NOT EXISTS keycloak; + CREATE SEQUENCE public.manufacturers_manufacturer_id_seq INCREMENT 1 START 1 @@ -85,6 +87,22 @@ CREATE TABLE IF NOT EXISTS public.firmware_upgrade_rules ON DELETE NO ACTION ); +CREATE SEQUENCE public.organizations_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.organizations +( + organization_id integer NOT NULL DEFAULT nextval('organizations_organization_id_seq'::regclass), + name character varying(128) COLLATE pg_catalog.default NOT NULL, + email character varying(128) COLLATE pg_catalog.default, + CONSTRAINT organizations_pkey PRIMARY KEY (organization_id), + CONSTRAINT organizations_name UNIQUE (name) +); + CREATE SEQUENCE public.rsu_credentials_credential_id_seq INCREMENT 1 START 1 @@ -98,8 +116,13 @@ CREATE TABLE IF NOT EXISTS public.rsu_credentials username character varying(128) COLLATE pg_catalog.default NOT NULL, password character varying(128) COLLATE pg_catalog.default NOT NULL, nickname character varying(128) COLLATE pg_catalog.default NOT NULL, + owner_organization_id integer NOT NULL, CONSTRAINT rsu_credentials_pkey PRIMARY KEY (credential_id), - CONSTRAINT rsu_credentials_nickname UNIQUE (nickname) + CONSTRAINT rsu_credentials_nickname UNIQUE (nickname), + CONSTRAINT fk_rsu_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION ); CREATE SEQUENCE public.snmp_credentials_snmp_credential_id_seq @@ -116,8 +139,13 @@ CREATE TABLE IF NOT EXISTS public.snmp_credentials password character varying(128) COLLATE pg_catalog.default NOT NULL, encrypt_password character varying(128) COLLATE pg_catalog.default, nickname character varying(128) COLLATE pg_catalog.default NOT NULL, + owner_organization_id integer NOT NULL, CONSTRAINT snmp_credentials_pkey PRIMARY KEY (snmp_credential_id), - CONSTRAINT snmp_credentials_nickname UNIQUE (nickname) + CONSTRAINT snmp_credentials_nickname UNIQUE (nickname), + CONSTRAINT fk_snmp_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION ); CREATE SEQUENCE public.snmp_protocols_snmp_protocol_id_seq @@ -189,6 +217,18 @@ CREATE TABLE IF NOT EXISTS public.rsus ON DELETE NO ACTION ); +CREATE TABLE IF NOT EXISTS public.rsu_options +( + rsu_id integer NOT NULL, + tim_deposit boolean NOT NULL DEFAULT FALSE, + snmp_monitoring boolean NOT NULL DEFAULT FALSE, + CONSTRAINT rsu_options_pkey PRIMARY KEY (rsu_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + CREATE SEQUENCE public.ping_ping_id_seq INCREMENT 1 START 1 @@ -244,22 +284,6 @@ CREATE TABLE IF NOT EXISTS public.users CONSTRAINT users_email UNIQUE (email) ); -CREATE SEQUENCE public.organizations_organization_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - -CREATE TABLE IF NOT EXISTS public.organizations -( - organization_id integer NOT NULL DEFAULT nextval('organizations_organization_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - email character varying(128) COLLATE pg_catalog.default, - CONSTRAINT organizations_pkey PRIMARY KEY (organization_id), - CONSTRAINT organizations_name UNIQUE (name) -); - CREATE SEQUENCE public.user_organization_user_organization_id_seq INCREMENT 1 START 1 @@ -328,7 +352,8 @@ CREATE TABLE IF NOT EXISTS public.iss_keys ( iss_key_id integer NOT NULL DEFAULT nextval('iss_keys_iss_key_id_seq'::regclass), common_name character varying(128) COLLATE pg_catalog.default NOT NULL, - token character varying(128) COLLATE pg_catalog.default NOT NULL + token character varying(128) COLLATE pg_catalog.default NOT NULL, + CONSTRAINT iss_keys_pkey PRIMARY KEY (iss_key_id) ); -- Create scms_health table @@ -403,9 +428,16 @@ CREATE SEQUENCE public.email_type_email_type_id_seq CREATE TABLE IF NOT EXISTS public.email_type ( email_type_id integer NOT NULL DEFAULT nextval('email_type_email_type_id_seq'::regclass), - CONSTRAINT email_type_pkey PRIMARY KEY (email_type_id), email_type character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT email_type_unique UNIQUE (email_type) + description character varying(256) COLLATE pg_catalog.default, + supports_immediate boolean DEFAULT true NOT NULL, + supports_hourly boolean DEFAULT false NOT NULL, + supports_daily boolean DEFAULT false NOT NULL, + supports_weekly boolean DEFAULT false NOT NULL, + supports_monthly boolean DEFAULT false NOT NULL, + CONSTRAINT email_type_pkey PRIMARY KEY (email_type_id), + CONSTRAINT email_type_unique UNIQUE (email_type), + CONSTRAINT at_least_one_frequency CHECK (supports_immediate OR supports_hourly OR supports_daily OR supports_weekly OR supports_monthly) ); CREATE SEQUENCE public.user_email_notification_user_email_notification_id_seq @@ -420,15 +452,22 @@ CREATE TABLE IF NOT EXISTS public.user_email_notification user_email_notification_id integer NOT NULL DEFAULT nextval('user_email_notification_user_email_notification_id_seq'::regclass), user_id integer NOT NULL, email_type_id integer NOT NULL, + immediate boolean DEFAULT true NOT NULL, + hourly boolean DEFAULT false NOT NULL, + daily boolean DEFAULT false NOT NULL, + weekly boolean DEFAULT false NOT NULL, + monthly boolean DEFAULT false NOT NULL, CONSTRAINT user_email_notification_pkey PRIMARY KEY (user_email_notification_id), + CONSTRAINT user_email_notification_unique UNIQUE (user_id, email_type_id), + CONSTRAINT at_least_one_subscription CHECK (immediate OR hourly OR daily OR weekly OR monthly), CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES public.users (user_id) MATCH SIMPLE ON UPDATE NO ACTION - ON DELETE NO ACTION, + ON DELETE CASCADE, CONSTRAINT fk_email_type_id FOREIGN KEY (email_type_id) REFERENCES public.email_type (email_type_id) MATCH SIMPLE ON UPDATE NO ACTION - ON DELETE NO ACTION + ON DELETE CASCADE ); CREATE SEQUENCE public.obu_ota_request_id_seq @@ -440,19 +479,18 @@ CREATE SEQUENCE public.obu_ota_request_id_seq CREATE TABLE IF NOT EXISTS public.obu_ota_requests ( request_id integer NOT NULL DEFAULT nextval('obu_ota_request_id_seq'::regclass), - obu_sn character varying(128) NOT NULL, - request_datetime timestamp NOT NULL, - origin_ip inet NOT NULL, + obu_sn character varying(128) NOT NULL, + request_datetime timestamp NOT NULL, + origin_ip inet NOT NULL, obu_firmware_version varchar(128) NOT NULL, requested_firmware_version varchar(128) NOT NULL, error_status bit(1) NOT NULL, error_message varchar(128) NOT NULL, manufacturer int4 NOT NULL, - CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) REFERENCES public.manufacturers(manufacturer_id) + CONSTRAINT obu_ota_requests_pkey PRIMARY KEY (request_id), + CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) REFERENCES public.manufacturers(manufacturer_id) ); -CREATE SCHEMA IF NOT EXISTS keycloak; - -- Intersections CREATE SEQUENCE public.intersections_intersection_id_seq INCREMENT 1 @@ -509,6 +547,7 @@ CREATE TABLE IF NOT EXISTS public.rsu_intersection rsu_id integer NOT NULL, intersection_id integer NOT NULL, CONSTRAINT rsu_intersection_pkey PRIMARY KEY (rsu_intersection_id), + CONSTRAINT rsu_intersection_unique UNIQUE (rsu_id, intersection_id), CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) REFERENCES public.rsus (rsu_id) MATCH SIMPLE ON UPDATE NO ACTION diff --git a/resources/sql_scripts/CVManager_SampleData.sql b/resources/deprecated/sql_scripts/CVManager_SampleData.sql similarity index 71% rename from resources/sql_scripts/CVManager_SampleData.sql rename to resources/deprecated/sql_scripts/CVManager_SampleData.sql index c19999107..7761810cc 100644 --- a/resources/sql_scripts/CVManager_SampleData.sql +++ b/resources/deprecated/sql_scripts/CVManager_SampleData.sql @@ -15,13 +15,17 @@ INSERT INTO public.firmware_upgrade_rules( from_id, to_id) VALUES (1, 2); +INSERT INTO public.organizations( + name) +VALUES ('Test Org'), ('Test Org 2'); + INSERT INTO public.rsu_credentials( - username, password, nickname) - VALUES ('username', 'password', 'cred1'); + username, password, nickname, owner_organization_id) + VALUES ('username', 'password', 'cred1', 1); INSERT INTO public.snmp_credentials( - username, password, encrypt_password, nickname) - VALUES ('username', 'password', 'encryption-pw', 'snmp1'); + username, password, encrypt_password, nickname, owner_organization_id) + VALUES ('username', 'password', 'encryption-pw', 'snmp1', 1); INSERT INTO public.snmp_protocols( protocol_code, nickname) @@ -35,9 +39,10 @@ INSERT INTO public.rsus( VALUES (ST_GeomFromText('POINT(-105.0135030 39.7405654)'), 1, '10.0.0.180', 'E5672', 'E5672', 'I999', 1, 1, 1, 1, 1, 1), (ST_GeomFromText('POINT(-104.987775 39.981805)'), 2, '10.0.0.78', 'E5321', 'E5321', 'I999', 1, 1, 1, 2, 2, 2); -INSERT INTO public.organizations( - name) - VALUES ('Test Org'), ('Test Org 2'); +INSERT INTO public.rsu_options( + rsu_id, tim_deposit, snmp_monitoring) + VALUES (1, TRUE, TRUE), (2, FALSE, TRUE); + INSERT INTO public.roles( name) @@ -71,8 +76,23 @@ INSERT INTO public.snmp_msgfwd_config( (2, 3, 2, 'SPAT', '10.0.0.80', 44910, '2024/04/01T00:00:00', '2034/04/01T00:00:00', '1', '0'); INSERT INTO public.email_type( - email_type) - VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'); + email_type, supports_immediate, supports_hourly, supports_daily, supports_weekly, supports_monthly) + VALUES ('Support Requests', true, false, false, false, false), + ('Firmware Upgrade Failures', true, false, false, false, false), + ('Daily Message Counts', true, false, false, false, false), + ('Access Requests', true, false, false, false, false), + ('Intersection Notification Summary', true, true, true, true, true), + ('Critical Error Messages', true, false, false, false, false); + +INSERT INTO public.user_email_notification( + user_email_notification_id, user_id, email_type_id, immediate, hourly, daily, weekly, monthly) + VALUES (1, 1, 1, true, false, false, false, false), + (2, 1, 2, true, false, false, false, false), + (3, 1, 3, true, false, false, false, false), + (4, 1, 4, true, false, false, false, false), + (5, 1, 5, true, true, true, true, true), + (6, 1, 6, true, false, false, false, false); + INSERT INTO public.intersections( intersection_number, ref_pt, intersection_name) diff --git a/resources/sql_scripts/README.md b/resources/deprecated/sql_scripts/README.md similarity index 94% rename from resources/sql_scripts/README.md rename to resources/deprecated/sql_scripts/README.md index 8b28a2c69..8555b03ab 100644 --- a/resources/sql_scripts/README.md +++ b/resources/deprecated/sql_scripts/README.md @@ -1,3 +1,7 @@ +> **DEPRECATED** — Manual SQL migration scripts in this directory have been replaced by +> [Flyway-managed migrations](../../db/README.md) in `resources/db/migration/`. +> This directory is kept as historical reference only. Do not add new migration scripts here. + # PostgreSQL SQL Scripts The CV Manager expects most of the data it utilizes to be stored in a PostgreSQL database. This PostgreSQL database can be hosted anywhere as long as proper networking rules have been configured. The tables of the database must be created using the provided SQL script to ensure the CV Manager will function properly. diff --git a/resources/deprecated/sql_scripts/update_scripts/add_missking_pkeys.sql b/resources/deprecated/sql_scripts/update_scripts/add_missking_pkeys.sql new file mode 100644 index 000000000..bc90ae257 --- /dev/null +++ b/resources/deprecated/sql_scripts/update_scripts/add_missking_pkeys.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.obu_ota_requests +ADD CONSTRAINT obu_ota_requests_pkey PRIMARY KEY (request_id); + +ALTER TABLE public.iss_keys +ADD CONSTRAINT iss_keys_pkey PRIMARY KEY (iss_key_id); \ No newline at end of file diff --git a/resources/deprecated/sql_scripts/update_scripts/add_owner_org_column_to_credential_tables.sql b/resources/deprecated/sql_scripts/update_scripts/add_owner_org_column_to_credential_tables.sql new file mode 100644 index 000000000..4cea6677b --- /dev/null +++ b/resources/deprecated/sql_scripts/update_scripts/add_owner_org_column_to_credential_tables.sql @@ -0,0 +1,75 @@ +-- alter rsu_credentials table +ALTER TABLE public.rsu_credentials + ADD COLUMN owner_organization_id INTEGER, + ADD CONSTRAINT fk_rsu_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES public.organizations (organization_id); + +UPDATE public.rsu_credentials rc +SET owner_organization_id = ( + SELECT ro.organization_id + FROM public.rsu_organization ro + JOIN public.rsus r ON ro.rsu_id = r.rsu_id + WHERE r.credential_id = rc.credential_id + ORDER BY r.rsu_id ASC + LIMIT 1 +); + + + +-- alter snmp_credentials table +ALTER TABLE public.snmp_credentials + ADD COLUMN owner_organization_id INTEGER, + ADD CONSTRAINT fk_snmp_credential_owner_organization_id FOREIGN KEY (owner_organization_id) + REFERENCES public.organizations (organization_id); + +UPDATE public.snmp_credentials sc +SET owner_organization_id = ( + SELECT ro.organization_id + FROM public.rsu_organization ro + JOIN public.rsus r ON ro.rsu_id = r.rsu_id + WHERE r.snmp_credential_id = sc.snmp_credential_id + ORDER BY r.rsu_id ASC + LIMIT 1 +); + +-- Create orphaned_credentials organization if there are any orphaned records +DO $$ + DECLARE + orphaned_credentials_org_id INTEGER; + BEGIN + -- Check if any orphaned records exist in either rsu_credentials or snmp_credentials + IF EXISTS (SELECT 1 FROM public.rsu_credentials WHERE owner_organization_id IS NULL) OR + EXISTS (SELECT 1 FROM public.snmp_credentials WHERE owner_organization_id IS NULL) THEN + + -- Attempt to insert the 'orphaned_credentials' organization. + -- If it already exists, the ON CONFLICT clause will prevent an error. + INSERT INTO public.organizations (name) + VALUES ('orphaned_credentials') + ON CONFLICT (name) DO NOTHING + RETURNING organization_id INTO orphaned_credentials_org_id; + + -- If the organization already existed, the RETURNING clause won't set orphaned_credentials_org_id. + -- In that case, we need to fetch the existing organization's ID. + IF orphaned_credentials_org_id IS NULL THEN + SELECT organization_id INTO orphaned_credentials_org_id + FROM public.organizations + WHERE name = 'orphaned_credentials'; + END IF; + + -- Update any orphaned rsu_credentials to point to the 'orphaned_credentials' organization + UPDATE public.rsu_credentials + SET owner_organization_id = orphaned_credentials_org_id + WHERE owner_organization_id IS NULL; + + -- Update any orphaned snmp_credentials to point to the 'orphaned_credentials' organization + UPDATE public.snmp_credentials + SET owner_organization_id = orphaned_credentials_org_id + WHERE owner_organization_id IS NULL; + END IF; + END $$; + +ALTER TABLE public.snmp_credentials + ALTER COLUMN owner_organization_id SET NOT NULL; + +ALTER TABLE public.rsu_credentials + ALTER COLUMN owner_organization_id SET NOT NULL; \ No newline at end of file diff --git a/resources/sql_scripts/update_scripts/email_notification_update.sql b/resources/deprecated/sql_scripts/update_scripts/email_notification_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/email_notification_update.sql rename to resources/deprecated/sql_scripts/update_scripts/email_notification_update.sql diff --git a/resources/deprecated/sql_scripts/update_scripts/email_subscription_type_update.sql b/resources/deprecated/sql_scripts/update_scripts/email_subscription_type_update.sql new file mode 100644 index 000000000..4100d4559 --- /dev/null +++ b/resources/deprecated/sql_scripts/update_scripts/email_subscription_type_update.sql @@ -0,0 +1,63 @@ +-- user_email_notification table changes +-- Add new columns to email_type table +ALTER TABLE public.email_type + ADD COLUMN IF NOT EXISTS description character varying(256), + ADD COLUMN IF NOT EXISTS supports_immediate boolean DEFAULT true NOT NULL, + ADD COLUMN IF NOT EXISTS supports_hourly boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS supports_daily boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS supports_weekly boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS supports_monthly boolean DEFAULT false NOT NULL; + +-- Add constraints to email_type (skip if already exists) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'email_type_unique') THEN + ALTER TABLE public.email_type ADD CONSTRAINT email_type_unique UNIQUE (email_type); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'at_least_one_frequency') THEN + ALTER TABLE public.email_type ADD CONSTRAINT at_least_one_frequency + CHECK (supports_immediate OR supports_hourly OR supports_daily OR supports_weekly OR supports_monthly); + END IF; +END $$; + + +-- Insert or update email types with specific frequency settings +INSERT INTO public.email_type(email_type, supports_immediate, supports_hourly, supports_daily, supports_weekly, supports_monthly) +VALUES + ('Support Requests', true, false, false, false, false), + ('Firmware Upgrade Failures', true, false, false, false, false), + ('Daily Message Counts', true, false, false, false, false), + ('Access Requests', true, false, false, false, false), + ('Intersection Notification Summary', true, true, true, true, true), + ('Critical Error Messages', true, false, false, false, false) +ON CONFLICT (email_type) +DO UPDATE SET + supports_immediate = EXCLUDED.supports_immediate, + supports_hourly = EXCLUDED.supports_hourly, + supports_daily = EXCLUDED.supports_daily, + supports_weekly = EXCLUDED.supports_weekly, + supports_monthly = EXCLUDED.supports_monthly; + + + +-- Add new columns to user_email_notification table +ALTER TABLE public.user_email_notification + ADD COLUMN IF NOT EXISTS immediate boolean DEFAULT true NOT NULL, + ADD COLUMN IF NOT EXISTS hourly boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS daily boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS weekly boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS monthly boolean DEFAULT false NOT NULL; + +-- Add constraints to user_email_notification (skip if already exists) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_email_notification_unique') THEN + ALTER TABLE public.user_email_notification ADD CONSTRAINT user_email_notification_unique UNIQUE (user_id, email_type_id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'at_least_one_subscription') THEN + ALTER TABLE public.user_email_notification ADD CONSTRAINT at_least_one_subscription + CHECK (immediate OR hourly OR daily OR weekly OR monthly); + END IF; +END $$; \ No newline at end of file diff --git a/resources/sql_scripts/update_scripts/firmware_max_retry_limit_update.sql b/resources/deprecated/sql_scripts/update_scripts/firmware_max_retry_limit_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/firmware_max_retry_limit_update.sql rename to resources/deprecated/sql_scripts/update_scripts/firmware_max_retry_limit_update.sql diff --git a/resources/sql_scripts/update_scripts/firmware_update.sql b/resources/deprecated/sql_scripts/update_scripts/firmware_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/firmware_update.sql rename to resources/deprecated/sql_scripts/update_scripts/firmware_update.sql diff --git a/resources/sql_scripts/update_scripts/intersection_update.sql b/resources/deprecated/sql_scripts/update_scripts/intersection_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/intersection_update.sql rename to resources/deprecated/sql_scripts/update_scripts/intersection_update.sql diff --git a/resources/sql_scripts/update_scripts/obu_ota_requests.sql b/resources/deprecated/sql_scripts/update_scripts/obu_ota_requests.sql similarity index 100% rename from resources/sql_scripts/update_scripts/obu_ota_requests.sql rename to resources/deprecated/sql_scripts/update_scripts/obu_ota_requests.sql diff --git a/resources/sql_scripts/update_scripts/org_email_notification_update.sql b/resources/deprecated/sql_scripts/update_scripts/org_email_notification_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/org_email_notification_update.sql rename to resources/deprecated/sql_scripts/update_scripts/org_email_notification_update.sql diff --git a/resources/sql_scripts/update_scripts/remove_rsu_map_info.sql b/resources/deprecated/sql_scripts/update_scripts/remove_rsu_map_info.sql similarity index 100% rename from resources/sql_scripts/update_scripts/remove_rsu_map_info.sql rename to resources/deprecated/sql_scripts/update_scripts/remove_rsu_map_info.sql diff --git a/resources/sql_scripts/update_scripts/rsu_health_update.sql b/resources/deprecated/sql_scripts/update_scripts/rsu_health_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/rsu_health_update.sql rename to resources/deprecated/sql_scripts/update_scripts/rsu_health_update.sql diff --git a/resources/deprecated/sql_scripts/update_scripts/rsu_options.sql b/resources/deprecated/sql_scripts/update_scripts/rsu_options.sql new file mode 100644 index 000000000..0a9c410cc --- /dev/null +++ b/resources/deprecated/sql_scripts/update_scripts/rsu_options.sql @@ -0,0 +1,16 @@ +-- Add the rsu_options table +CREATE TABLE IF NOT EXISTS public.rsu_options ( + rsu_id integer NOT NULL, + tim_deposit boolean NOT NULL DEFAULT FALSE, + snmp_monitoring boolean NOT NULL DEFAULT FALSE, + CONSTRAINT rsu_options_pkey PRIMARY KEY (rsu_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +-- Populate existing rows for tim_deposit +INSERT INTO public.rsu_options (rsu_id, tim_deposit, snmp_monitoring) +SELECT rsu_id, FALSE, FALSE FROM public.rsus +ON CONFLICT (rsu_id) DO NOTHING; \ No newline at end of file diff --git a/resources/sql_scripts/update_scripts/rsus_snmp_versions_update.sql b/resources/deprecated/sql_scripts/update_scripts/rsus_snmp_versions_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/rsus_snmp_versions_update.sql rename to resources/deprecated/sql_scripts/update_scripts/rsus_snmp_versions_update.sql diff --git a/resources/deprecated/sql_scripts/update_scripts/scms_health_index.sql b/resources/deprecated/sql_scripts/update_scripts/scms_health_index.sql new file mode 100644 index 000000000..c4ddb52e6 --- /dev/null +++ b/resources/deprecated/sql_scripts/update_scripts/scms_health_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_scms_health_timestamp + ON scms_health(timestamp); \ No newline at end of file diff --git a/resources/sql_scripts/update_scripts/snmp_credentials_update.sql b/resources/deprecated/sql_scripts/update_scripts/snmp_credentials_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/snmp_credentials_update.sql rename to resources/deprecated/sql_scripts/update_scripts/snmp_credentials_update.sql diff --git a/resources/sql_scripts/update_scripts/snmp_msgfwd.sql b/resources/deprecated/sql_scripts/update_scripts/snmp_msgfwd.sql similarity index 100% rename from resources/sql_scripts/update_scripts/snmp_msgfwd.sql rename to resources/deprecated/sql_scripts/update_scripts/snmp_msgfwd.sql diff --git a/resources/sql_scripts/update_scripts/snmp_msgfwd_security.sql b/resources/deprecated/sql_scripts/update_scripts/snmp_msgfwd_security.sql similarity index 100% rename from resources/sql_scripts/update_scripts/snmp_msgfwd_security.sql rename to resources/deprecated/sql_scripts/update_scripts/snmp_msgfwd_security.sql diff --git a/resources/sql_scripts/update_scripts/snmp_version_update.sql b/resources/deprecated/sql_scripts/update_scripts/snmp_version_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/snmp_version_update.sql rename to resources/deprecated/sql_scripts/update_scripts/snmp_version_update.sql diff --git a/resources/sql_scripts/update_scripts/user_provider_table_update.sql b/resources/deprecated/sql_scripts/update_scripts/user_provider_table_update.sql similarity index 100% rename from resources/sql_scripts/update_scripts/user_provider_table_update.sql rename to resources/deprecated/sql_scripts/update_scripts/user_provider_table_update.sql diff --git a/resources/keycloak/README.md b/resources/keycloak/README.md index 3cc13c93f..4d8058228 100644 --- a/resources/keycloak/README.md +++ b/resources/keycloak/README.md @@ -74,3 +74,75 @@ This section describes the steps required to add this custom user provider to an 9. Complete - Now, users can login through the google IDP, and their newly-created keycloak identities will be automatically linked to their existing postgres information! - In the future, consider reverting the changes to the first broker login authentication flow + +## Service Account Creation + +Several CV-Manager services utilize the Intersection API to generate emails. This includes the message-counts addon, the firmware upgrade runner addon, and the cvmanager (python) api. Each of these services must authenticate to Keycloak to make requests to the Intersection API. This authentication is facilitated through creating service accounts for each service. Use the following steps to create and configure the required service accounts: + +1. Navigate to the keycloak admin console, and select the "cvmanager" realm +2. Create realm roles + - Under "Manage", select the "Realm roles" tab + - Select "Create Role" + - Create the following 3 roles (these are case sensitive): + 1. ROLE_SEND_MESSAGE_COUNTS_EMAILS + - description: "Role enabling services to send CV message count summary emails through the intersection API" + 2. ROLE_SEND_FIRMWARE_UPGRADE_EMAILS + - description: "Role enabling services to send firmware upgrade failure emails through the intersection API" + 3. ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS + - description: "Role enabling services to send critical API error message/summary emails through the intersection API" +3. Create the message count service account + - Under "Manage", select "Clients" + - select "Create Client" + - Enter the following information for General settings: + - Client ID: sa_count_metric + - Hit "Next" to continue capability config + - Client Authentication: On + - Under Authentication flow, make sure the "Service accounts roles" is checked + - Hit "Next" and then "Save" + - Under the "Credentials" tab, save the Client Secret + - Under "Client Secret", press the eye icon to view the secret value + - select and copy the client secret + - save the client secret as the ENV variable "KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY" in your .env + - Under the "Service accounts roles" tab, select "Assign Role" + - Ensure the filter is set to "Filter by realm roles" + - Select the role "ROLE_SEND_MESSAGE_COUNTS_EMAILS" and hit "Assign" + - You should see "ROLE_SEND_MESSAGE_COUNTS_EMAILS" in the list of roles +4. Create the firmware upgrade runner service account + - Under "Manage", select "Clients" + - select "Create Client" + - Enter the following information for General settings: + - Client ID: sa_firmware_upgrade_runner + - Hit "Next" to continue capability config + - Client Authentication: On + - Under Authentication flow, make sure the "Service accounts roles" is checked + - Hit "Next" and then "Save" + - Under the "Credentials" tab, save the Client Secret + - Under "Client Secret", press the eye icon to view the secret value + - select and copy the client secret + - save the client secret as the ENV variable "KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY" in your .env + - Under the "Service accounts roles" tab, select "Assign Role" + - Ensure the filter is set to "Filter by realm roles" + - Select the role "ROLE_SEND_FIRMWARE_UPGRADE_EMAILS" and hit "Assign" + - You should see "ROLE_SEND_FIRMWARE_UPGRADE_EMAILS" in the list of roles +5. Create the cvmanager python API service account + - Under "Manage", select "Clients" + - select "Create Client" + - Enter the following information for General settings: + - Client ID: sa_cvmanager_python_api + - Hit "Next" to continue capability config + - Client Authentication: On + - Under Authentication flow, make sure the "Service accounts roles" is checked + - Hit "Next" and then "Save" + - Under the "Credentials" tab, save the Client Secret + - Under "Client Secret", press the eye icon to view the secret value + - select and copy the client secret + - save the client secret as the ENV variable "KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY" in your .env + - Under the "Service accounts roles" tab, select "Assign Role" + - Ensure the filter is set to "Filter by realm roles" + - Select the role "ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS" and hit "Assign" + - You should see "ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS" in the list of roles +6. Create the cvmanager Intersection API service account + - Under "Clients", select the `cvmanager-api` client + - Under the "Settings" tab, scroll down to the "Capability Config" section and ensure that "Service accounts roles" is checked + - Under the new "Service accounts roles" tab, select "Assign role" + - Assign the following roles: `manage-users`, `view-users` diff --git a/resources/keycloak/custom-user-provider/src/main/java/com/cvmanager/auth/provider/user/CustomUserStorageProvider.java b/resources/keycloak/custom-user-provider/src/main/java/com/cvmanager/auth/provider/user/CustomUserStorageProvider.java index 011f0c534..17db89090 100644 --- a/resources/keycloak/custom-user-provider/src/main/java/com/cvmanager/auth/provider/user/CustomUserStorageProvider.java +++ b/resources/keycloak/custom-user-provider/src/main/java/com/cvmanager/auth/provider/user/CustomUserStorageProvider.java @@ -302,7 +302,7 @@ public UserAdapter updateUser(RealmModel realm, UserObject user) { st.setString(3, user.getLastName()); st.setLong(4, user.getCreatedTimestamp()); st.setInt(5, user.getSuperUser()); - st.setString(6, user.getId()); + st.setString(6, getUuidFromKeycloakId(user.getId())); log.debug("updateUser: st={}", st); st.executeUpdate(); ResultSet rs = st.getGeneratedKeys(); @@ -324,7 +324,7 @@ public boolean removeUser(RealmModel realm, UserModel user) { // remove user with ID from db PreparedStatement st = c.prepareStatement( "delete from public.users where keycloak_id = ?::UUID"); - st.setString(1, user.getId()); + st.setString(1, getUuidFromKeycloakId(user.getId())); log.debug("removeUser: st={}", st); int rowsAffected = st.executeUpdate(); return rowsAffected > 0; @@ -332,4 +332,15 @@ public boolean removeUser(RealmModel realm, UserModel user) { throw new RuntimeStorageException(ex); } } + + private String getUuidFromKeycloakId(String keycloakId) { + // Support keycloakId formats such as: + // - 9df45cb2-8582-4550-8140-dfb4712cd6c3 + // - f:60b8a4e7-d427-4316-9ec0-cb8a6eeb34bd:9df45cb2-8582-4550-8140-dfb4712cd6c3 + if (keycloakId.contains(":")) { + return keycloakId.substring(keycloakId.lastIndexOf(':') + 1); + } else { + return keycloakId; + } + } } diff --git a/resources/keycloak/realm.json b/resources/keycloak/realm.json index 36fe2dfdd..e4db5a9f2 100644 --- a/resources/keycloak/realm.json +++ b/resources/keycloak/realm.json @@ -57,6 +57,33 @@ "containerId": "da22ac1b-0b52-4419-af5c-18c407b249e5", "attributes": {} }, + { + "id": "3ebf56b9-b3cb-4381-a071-bc1405f0df01", + "name": "ROLE_SEND_FIRMWARE_UPGRADE_EMAILS", + "description": "", + "composite": false, + "clientRole": false, + "containerId": "da22ac1b-0b52-4419-af5c-18c407b249e5", + "attributes": {} + }, + { + "id": "34abb452-d506-4ea4-9002-8fc2654da3e0", + "name": "ROLE_SEND_MESSAGE_COUNTS_EMAILS", + "description": "", + "composite": false, + "clientRole": false, + "containerId": "da22ac1b-0b52-4419-af5c-18c407b249e5", + "attributes": {} + }, + { + "id": "f2d6b54b-7ed7-44a7-af9d-78a9d654e77b", + "name": "ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS", + "description": "", + "composite": false, + "clientRole": false, + "containerId": "da22ac1b-0b52-4419-af5c-18c407b249e5", + "attributes": {} + }, { "id": "8c98c104-30a8-446e-acca-88d2f21c85d1", "name": "uma_authorization", @@ -293,6 +320,8 @@ "cvmanager-api": [], "security-admin-console": [], "admin-cli": [], + "sa_firmware_upgrade_runner": [], + "sa_cvmanager_python_api": [], "account-console": [], "cvmanager-gui": [], "broker": [ @@ -306,6 +335,7 @@ "attributes": {} } ], + "sa_count_metric": [], "account": [ { "id": "c4f5bcad-c192-4f3b-8d50-f57be085a58c", @@ -434,6 +464,69 @@ "webAuthnPolicyPasswordlessAcceptableAaguids": [], "webAuthnPolicyPasswordlessExtraOrigins": [], "users": [ + { + "id": "f4f11ef4-334c-4d62-9dd1-a23cf71c81c7", + "username": "service-account-sa_count_metric", + "emailVerified": false, + "createdTimestamp": 1767374022310, + "enabled": true, + "totp": false, + "serviceAccountClientId": "sa_count_metric", + "credentials": [], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["ROLE_SEND_MESSAGE_COUNTS_EMAILS", "default-roles-cvmanager"], + "notBefore": 0, + "groups": [] + }, + { + "id": "6d1aebe0-fa83-4e0d-995d-3733947b4e98", + "username": "service-account-sa_cvmanager_python_api", + "emailVerified": false, + "createdTimestamp": 1767375249882, + "enabled": true, + "totp": false, + "serviceAccountClientId": "sa_cvmanager_python_api", + "credentials": [], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS", "default-roles-cvmanager"], + "notBefore": 0, + "groups": [] + }, + { + "id": "d0ca0c70-f62d-4b63-a07e-cbf9f1d54f87", + "username": "service-account-sa_firmware_upgrade_runner", + "emailVerified": false, + "createdTimestamp": 1767374599632, + "enabled": true, + "totp": false, + "serviceAccountClientId": "sa_firmware_upgrade_runner", + "credentials": [], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["ROLE_SEND_FIRMWARE_UPGRADE_EMAILS", "default-roles-cvmanager"], + "notBefore": 0, + "groups": [] + }, + { + "id": "47606489-6d04-45c3-9c8e-3bca32f53092", + "username": "service-account-cvmanager-api", + "emailVerified": false, + "createdTimestamp": 1777915879213, + "enabled": true, + "totp": false, + "serviceAccountClientId": "cvmanager-api", + "credentials": [], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-cvmanager"], + "clientRoles": { + "realm-management": ["manage-users", "view-users"] + }, + "notBefore": 0, + "groups": [] + }, { "id": "fc3d8729-8526-4aaa-805b-d64bf3b93860", "username": "test@gmail.com", @@ -442,8 +535,8 @@ "email": "test@gmail.com", "emailVerified": false, "attributes": { - "organizations": ["[{\"org\":\"Test Org\",\"role\":\"admin\"},{\"org\":\"Test Org 2\",\"role\":\"user\"}]"], "super_user": ["1"], + "organizations": ["[{\"org\":\"Test Org\",\"role\":\"admin\"},{\"org\":\"Test Org 2\",\"role\":\"user\"}]"], "created_timestamp": ["1746773527283"] }, "origin": "60b8a4e7-d427-4316-9ec0-cb8a6eeb34bd", @@ -474,6 +567,9 @@ "federatedUsers": [ { "id": "fc3d8729-8526-4aaa-805b-d64bf3b93860", + "attributes": { + "fedNotBefore": ["0"] + }, "credentials": [ { "id": "64f66e4b-868d-4805-835d-dba761c6e32b", @@ -600,7 +696,8 @@ "protocol": "openid-connect", "attributes": { "realm_client": "false", - "client.use.lightweight.access.token.enabled": "true" + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+" }, "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, @@ -629,7 +726,8 @@ "frontchannelLogout": false, "protocol": "openid-connect", "attributes": { - "realm_client": "true" + "realm_client": "true", + "post.logout.redirect.uris": "+" }, "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, @@ -658,7 +756,7 @@ "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, + "serviceAccountsEnabled": true, "publicClient": false, "frontchannelLogout": true, "protocol": "openid-connect", @@ -667,6 +765,7 @@ "oidc.ciba.grant.enabled": "false", "client.secret.creation.time": "1746774522", "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", "oauth2.device.authorization.grant.enabled": "false", "backchannel.logout.revoke.offline.tokens": "false" }, @@ -688,8 +787,20 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["http://localhost:3000/*", "http://localhost/*", "http://cvmanager.local.com/*"], - "webOrigins": ["http://localhost:3000", "http://localhost", "http://cvmanager.local.com"], + "redirectUris": [ + "http://localhost:3000/*", + "http://localhost:3001/*", + "http://localhost:3002/*", + "http://localhost/*", + "${WEBAPP_ENDPOINT}/*" + ], + "webOrigins": [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost", + "${WEBAPP_ENDPOINT}" + ], "notBefore": 0, "bearerOnly": false, "consentRequired": false, @@ -704,6 +815,7 @@ "realm_client": "false", "oidc.ciba.grant.enabled": "false", "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", "oauth2.device.authorization.grant.enabled": "false", "backchannel.logout.revoke.offline.tokens": "false" }, @@ -749,7 +861,8 @@ "frontchannelLogout": false, "protocol": "openid-connect", "attributes": { - "realm_client": "true" + "realm_client": "true", + "post.logout.redirect.uris": "+" }, "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, @@ -757,6 +870,295 @@ "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], "optionalClientScopes": ["address", "phone", "organization", "offline_access", "microprofile-jwt"] }, + { + "id": "e327e402-24ce-41d0-b53a-a06738e386ec", + "clientId": "sa_count_metric", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY}", + "redirectUris": ["/*"], + "webOrigins": ["/*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "28800", + "client.secret.creation.time": "1767373814", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "tls.client.certificate.bound.access.tokens": "false", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "befc7a5c-498d-4d06-815b-e70eade24510", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "28ba523b-cde8-4b49-90e1-af2d78a04816", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "d1a41790-7941-42bb-869c-48429052c7c5", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "organization", "offline_access", "microprofile-jwt"] + }, + { + "id": "679891a3-6396-41a7-85d4-7e654a1bdcaa", + "clientId": "sa_cvmanager_python_api", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY}", + "redirectUris": ["/*"], + "webOrigins": ["/*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "28800", + "client.secret.creation.time": "1767375249", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "tls.client.certificate.bound.access.tokens": "false", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "5e720345-2560-4699-828a-1b2b03caedb3", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "529e33a3-9d5d-47c9-82f1-1a12f1399ac9", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "cfc4ad1f-382e-42ec-8c77-b61064d6096e", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "organization", "offline_access", "microprofile-jwt"] + }, + { + "id": "9728749e-eb5b-4c7a-b6dd-bb8cf3cd0297", + "clientId": "sa_firmware_upgrade_runner", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY}", + "redirectUris": ["/*"], + "webOrigins": ["/*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "28800", + "client.secret.creation.time": "1767374599", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "tls.client.certificate.bound.access.tokens": "false", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "b5d31ff0-d1e3-4d99-a453-2b85fbb0ae09", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "4d9dd561-051a-41a7-9bdf-1b046080c324", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "4c83b212-a1b6-4099-bce7-01311db69bc1", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "organization", "offline_access", "microprofile-jwt"] + }, { "id": "34357295-9261-4c7c-b1bd-634a62ca275a", "clientId": "security-admin-console", @@ -865,6 +1267,7 @@ "config": { "introspection.token.claim": "true", "multivalued": "true", + "userinfo.token.claim": "true", "user.attribute": "foo", "id.token.claim": "true", "access.token.claim": "true", @@ -991,12 +1394,13 @@ "protocolMapper": "oidc-organization-membership-mapper", "consentRequired": false, "config": { - "id.token.claim": "true", "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", "access.token.claim": "true", "claim.name": "organization", - "jsonType.label": "String", - "multivalued": "true" + "jsonType.label": "String" } } ] @@ -1155,7 +1559,8 @@ "config": { "id.token.claim": "true", "introspection.token.claim": "true", - "access.token.claim": "true" + "access.token.claim": "true", + "userinfo.token.claim": "true" } } ] @@ -1412,8 +1817,9 @@ "consentRequired": false, "config": { "user.session.note": "AUTH_TIME", - "id.token.claim": "true", "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", "access.token.claim": "true", "claim.name": "auth_time", "jsonType.label": "long" @@ -1501,14 +1907,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-full-name-mapper", - "oidc-address-mapper", "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", - "saml-user-attribute-mapper" + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper" ] } }, @@ -1538,14 +1944,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", - "saml-role-list-mapper", - "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", - "oidc-address-mapper", + "oidc-full-name-mapper", "saml-user-property-mapper", - "oidc-full-name-mapper" + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-address-mapper" ] } }, @@ -1588,7 +1994,9 @@ "providerId": "custom-user-provider", "subComponents": {}, "config": { - "JDBC_URL": ["${KC_DB_URL}"], + "JDBC_URL": [ + "jdbc:postgresql://${KC_DB_URL_HOST}:${KC_DB_URL_PORT}/${KC_DB_URL_DATABASE}?currentSchema=${KC_DB_SCHEMA}" + ], "DB_USERNAME": ["${KC_DB_USERNAME}"], "VALIDATION_QUERY": ["select 1"], "cachePolicy": ["NO_CACHE"], @@ -2393,13 +2801,18 @@ "firstBrokerLoginFlow": "first broker login", "attributes": { "cibaBackchannelTokenDeliveryMode": "poll", - "cibaExpiresIn": "120", "cibaAuthRequestedUserHint": "login_hint", - "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", "oauth2DevicePollingInterval": "5", - "parRequestUriLifespan": "60", + "clientSessionIdleTimeout": "0", + "clientOfflineSessionIdleTimeout": "0", "cibaInterval": "5", - "realmReusableOtpCode": "false" + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "organizationsEnabled": "false" }, "keycloakVersion": "26.0.5", "userManagedAccessAllowed": false, diff --git a/resources/keycloak/sample_theme.jar b/resources/keycloak/sample_theme.jar index b8a01cc87..5bd0af813 100644 Binary files a/resources/keycloak/sample_theme.jar and b/resources/keycloak/sample_theme.jar differ diff --git a/resources/kubernetes/README.md b/resources/kubernetes/README.md index 75beaa69f..049ba39df 100644 --- a/resources/kubernetes/README.md +++ b/resources/kubernetes/README.md @@ -10,6 +10,55 @@ The YAML files use GCP specific specifications for various values such as "netwo The environment variables must be set according to the README documentation for each application. The iss-health-check application supports GCP or postgres for storing keys. The environment variables for the iss-health-check application must be set according to the README documentation for the iss-health-check application. +## Database Migrations (Flyway) + +Schema initialization and migrations are managed by Flyway via `cv-manager-flyway.yaml`. This replaces the old `pg-init-tables` ConfigMap approach, which only ran on a fresh (empty) database and contained a stale schema missing many tables. + +The Flyway Job uses a custom Docker image built from `resources/db/Dockerfile`. This image bundles the versioned migration SQL files directly — `R__sample_data.sql` is excluded because the Dockerfile copies only `V*.sql` files. + +**Building and pushing the image:** + +CI publishes this image automatically. On every merge to `develop` or `cdot-release*`, the `build_flyway_image` workflow job builds and pushes to: + +``` +ghcr.io//cvmanager-flyway:sha- +ghcr.io//cvmanager-flyway: +``` + +To deploy a migration set, copy the `sha-` tag from the CI run that corresponds to the commit you want, then update the `image:` field in `cv-manager-flyway.yaml` and re-apply the Job. + +To build and push manually (e.g. from a fork without CI configured): + +```sh +docker build -f resources/db/Dockerfile -t ghcr.io//cvmanager-flyway: resources/db/ +docker push ghcr.io//cvmanager-flyway: +``` + +Update the `image:` field in `cv-manager-flyway.yaml` to match the pushed tag before deploying. + +**Deployment order:** + +1. Apply Postgres and the Flyway Job together: + ```sh + kubectl apply -f cv-manager-postgres.yaml + kubectl apply -f cv-manager-flyway.yaml + ``` +2. Wait for the Job to complete: + ```sh + kubectl wait --for=condition=complete job/cv-manager-flyway-migrate --timeout=120s + ``` +3. Apply remaining services. The `cv-manager-api` Deployment includes an init container that polls `flyway_schema_history` and will not start until migrations succeed. + +**Adding a new migration:** + +1. Create `V{N}__description.sql` in `resources/db/migration/` following the naming conventions in [`resources/db/README.md`](../db/README.md). +2. Merge to `develop` or `cdot-release*`. CI rebuilds and pushes the image automatically (the Dockerfile `COPY migration/V*.sql` glob picks up new files). To build manually, see the instructions above. +3. Update the `image:` tag in `cv-manager-flyway.yaml`, delete the old Job, and re-apply: + ```sh + kubectl delete job cv-manager-flyway-migrate + kubectl apply -f cv-manager-flyway.yaml + ``` + ## Useful Links - [Learn about and get started with Kubernetes](https://kubernetes.io/docs/tutorials/kubernetes-basics/) diff --git a/resources/kubernetes/cv-manager-api.yaml b/resources/kubernetes/cv-manager-api.yaml index 11a2f0192..ee16b1daf 100644 --- a/resources/kubernetes/cv-manager-api.yaml +++ b/resources/kubernetes/cv-manager-api.yaml @@ -81,6 +81,35 @@ spec: labels: app: cv-manager-api spec: + initContainers: + - name: wait-for-migrations + image: postgres:15-alpine + command: + - sh + - -c + - | + : "${PG_DB_NAME:?PG_DB_NAME must be set}" + until PGPASSWORD="$PG_DB_PASS" psql \ + -h cv-manager-postgres-svc -p 5432 \ + -U "$PG_DB_USER" -d "$PG_DB_NAME" \ + -c "SELECT 1 FROM flyway_schema_history WHERE success = true LIMIT 1" > /dev/null 2>&1; do + echo "Waiting for Flyway migrations to complete..." + sleep 5 + done + env: + # Fill in PG_DB_NAME to match the value used in the main container below + - name: PG_DB_NAME + value: "" + - name: PG_DB_USER + valueFrom: + secretKeyRef: + name: some-postgres-secret-user + key: some-postgres-secret-key + - name: PG_DB_PASS + valueFrom: + secretKeyRef: + name: some-postgres-secret-password + key: some-postgres-secret-key containers: - name: cv-manager-api imagePullPolicy: Always @@ -132,15 +161,13 @@ spec: value: "" - name: MONGO_DB_NAME value: "" - - name: COUNTS_MSG_TYPES - value: "" - name: MONGO_PROCESSED_BSM_COLLECTION_NAME value: "" - name: MONGO_PROCESSED_PSM_COLLECTION_NAME value: "" - - name: SSM_DB_NAME + - name: MONGO_SSM_COLLECTION_NAME value: "" - - name: SRM_DB_NAME + - name: MONGO_SRM_COLLECTION_NAME value: "" - name: WZDX_ENDPOINT value: "" diff --git a/resources/kubernetes/cv-manager-flyway.yaml b/resources/kubernetes/cv-manager-flyway.yaml new file mode 100644 index 000000000..d72c842dd --- /dev/null +++ b/resources/kubernetes/cv-manager-flyway.yaml @@ -0,0 +1,44 @@ +# Job that runs Flyway migrations before the CV Manager API starts. +# Requires a pre-built cvmanager-flyway image. See resources/db/Dockerfile and +# resources/kubernetes/README.md for build and push instructions. +# +# Apply this before scaling up cv-manager-api. The API's init container +# waits for a successful row in flyway_schema_history before starting. +# +# To add a new migration: +# 1. Create a migration in resources/db/migration/ following the standard outlined in resources/db/README.md +# 2. Rebuild and push the cvmanager-flyway image +# 3. Update the image tag below, delete the old Job, and re-apply +apiVersion: batch/v1 +kind: Job +metadata: + name: cv-manager-flyway-migrate +spec: + ttlSecondsAfterFinished: 3600 + backoffLimit: 3 + template: + spec: + restartPolicy: OnFailure + containers: + - name: flyway + # Replace with your GitHub org/user and set the tag to the + # git SHA of the migrations you want to deploy. + # CI pushes to: ghcr.io//cvmanager-flyway:sha- + image: ghcr.io/your-org/cvmanager-flyway:sha- + command: ["flyway", "migrate"] + env: + # Fill in PG_DB_NAME to match the value used in cv-manager-api.yaml + - name: PG_DB_NAME + value: "" # Required: set to your database name (matches PG_DB_NAME in cv-manager-api) + - name: FLYWAY_URL + value: jdbc:postgresql://cv-manager-postgres-svc:5432/$(PG_DB_NAME) + - name: FLYWAY_USER + valueFrom: + secretKeyRef: + name: some-postgres-secret-user + key: some-postgres-secret-key + - name: FLYWAY_PASSWORD + valueFrom: + secretKeyRef: + name: some-postgres-secret-password + key: some-postgres-secret-key diff --git a/resources/kubernetes/cv-manager-postgres.yaml b/resources/kubernetes/cv-manager-postgres.yaml index 7b201f738..11cf2c877 100644 --- a/resources/kubernetes/cv-manager-postgres.yaml +++ b/resources/kubernetes/cv-manager-postgres.yaml @@ -74,9 +74,6 @@ spec: - name: cv-manager-postgres-volume persistentVolumeClaim: claimName: cv-manager-postgres-claim - - name: cv-manager-init-tables - configMap: - name: pg-init-tables containers: - name: 'cv-manager-postgis' imagePullPolicy: Always @@ -104,348 +101,3 @@ spec: volumeMounts: - name: cv-manager-postgres-volume mountPath: /var/lib/postgresql/data - - name: cv-manager-init-tables - mountPath: /docker-entrypoint-initdb.d ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: pg-init-tables -data: - create-tables.sql: |- - CREATE EXTENSION IF NOT EXISTS postgis; - - CREATE SEQUENCE public.manufacturers_manufacturer_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.manufacturers - ( - manufacturer_id integer NOT NULL DEFAULT nextval('manufacturers_manufacturer_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT manufacturers_pkey PRIMARY KEY (manufacturer_id), - CONSTRAINT manufacturers_name UNIQUE (name) - ); - - CREATE SEQUENCE public.rsu_models_rsu_model_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.rsu_models - ( - rsu_model_id integer NOT NULL DEFAULT nextval('rsu_models_rsu_model_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - supported_radio character varying(128) COLLATE pg_catalog.default NOT NULL, - manufacturer integer NOT NULL, - CONSTRAINT rsu_models_pkey PRIMARY KEY (rsu_model_id), - CONSTRAINT rsu_models_name UNIQUE (name), - CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) - REFERENCES public.manufacturers (manufacturer_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.firmware_images_firmware_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.firmware_images - ( - firmware_id integer NOT NULL DEFAULT nextval('firmware_images_firmware_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - model integer NOT NULL, - install_package character varying(128) COLLATE pg_catalog.default NOT NULL, - version character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT firmware_images_pkey PRIMARY KEY (firmware_id), - CONSTRAINT firmware_images_name UNIQUE (name), - CONSTRAINT firmware_images_install_package UNIQUE (install_package), - CONSTRAINT firmware_images_version UNIQUE (version), - CONSTRAINT fk_model FOREIGN KEY (model) - REFERENCES public.rsu_models (rsu_model_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.firmware_upgrade_rules_firmware_upgrade_rule_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.firmware_upgrade_rules - ( - firmware_upgrade_rule_id integer NOT NULL DEFAULT nextval('firmware_upgrade_rules_firmware_upgrade_rule_id_seq'::regclass), - from_id integer NOT NULL, - to_id integer NOT NULL, - CONSTRAINT firmware_upgrade_rules_pkey PRIMARY KEY (firmware_upgrade_rule_id), - CONSTRAINT fk_from_id FOREIGN KEY (from_id) - REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_to_id FOREIGN KEY (to_id) - REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.rsu_credentials_credential_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.rsu_credentials - ( - credential_id integer NOT NULL DEFAULT nextval('rsu_credentials_credential_id_seq'::regclass), - username character varying(128) COLLATE pg_catalog.default NOT NULL, - password character varying(128) COLLATE pg_catalog.default NOT NULL, - nickname character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT rsu_credentials_pkey PRIMARY KEY (credential_id), - CONSTRAINT rsu_credentials_nickname UNIQUE (nickname) - ); - - CREATE SEQUENCE public.snmp_credentials_snmp_credential_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.snmp_credentials - ( - snmp_credential_id integer NOT NULL DEFAULT nextval('snmp_credentials_snmp_credential_id_seq'::regclass), - username character varying(128) COLLATE pg_catalog.default NOT NULL, - password character varying(128) COLLATE pg_catalog.default NOT NULL, - encrypt_password character varying(128) COLLATE pg_catalog.default, - nickname character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT snmp_credentials_pkey PRIMARY KEY (snmp_credential_id), - CONSTRAINT snmp_credentials_nickname UNIQUE (nickname) - ); - - CREATE SEQUENCE public.snmp_protocols_snmp_protocol_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.snmp_protocols - ( - snmp_protocol_id integer NOT NULL DEFAULT nextval('snmp_protocols_snmp_protocol_id_seq'::regclass), - protocol_code character varying(128) COLLATE pg_catalog.default NOT NULL, - nickname character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT snmp_protocols_pkey PRIMARY KEY (snmp_protocol_id), - CONSTRAINT snmp_protocols_nickname UNIQUE (nickname) - ); - - CREATE SEQUENCE public.rsus_rsu_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.rsus - ( - rsu_id integer NOT NULL DEFAULT nextval('rsus_rsu_id_seq'::regclass), - geography geography NOT NULL, - milepost double precision NOT NULL, - ipv4_address inet NOT NULL, - serial_number character varying(128) COLLATE pg_catalog.default NOT NULL, - iss_scms_id character varying(128) COLLATE pg_catalog.default NOT NULL, - primary_route character varying(128) COLLATE pg_catalog.default NOT NULL, - model integer NOT NULL, - credential_id integer NOT NULL, - snmp_credential_id integer NOT NULL, - snmp_protocol_id integer NOT NULL, - firmware_version integer, - target_firmware_version integer, - CONSTRAINT rsu_pkey PRIMARY KEY (rsu_id), - CONSTRAINT rsu_ipv4_address UNIQUE (ipv4_address), - CONSTRAINT rsu_milepost_primary_route UNIQUE (milepost, primary_route), - CONSTRAINT rsu_serial_number UNIQUE (serial_number), - CONSTRAINT rsu_iss_scms_id UNIQUE (iss_scms_id), - CONSTRAINT fk_model FOREIGN KEY (model) - REFERENCES public.rsu_models (rsu_model_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_credential_id FOREIGN KEY (credential_id) - REFERENCES public.rsu_credentials (credential_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_snmp_credential_id FOREIGN KEY (snmp_credential_id) - REFERENCES public.snmp_credentials (snmp_credential_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_snmp_protocol_id FOREIGN KEY (snmp_protocol_id) - REFERENCES public.snmp_protocols (snmp_protocol_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_firmware_version FOREIGN KEY (firmware_version) - REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_target_firmware_version FOREIGN KEY (target_firmware_version) - REFERENCES public.firmware_images (firmware_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.ping_ping_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.ping - ( - ping_id integer NOT NULL DEFAULT nextval('ping_ping_id_seq'::regclass), - timestamp timestamp without time zone NOT NULL, - result bit(1) NOT NULL, - rsu_id integer NOT NULL, - CONSTRAINT ping_pkey PRIMARY KEY (ping_id), - CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) - REFERENCES public.rsus (rsu_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.roles_role_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.roles - ( - role_id integer NOT NULL DEFAULT nextval('roles_role_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT roles_pkey PRIMARY KEY (role_id), - CONSTRAINT roles_name UNIQUE (name) - ); - - CREATE SEQUENCE public.users_user_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.users - ( - user_id integer NOT NULL DEFAULT nextval('users_user_id_seq'::regclass), - email character varying(128) COLLATE pg_catalog.default NOT NULL, - first_name character varying(128) NOT NULL, - last_name character varying(128) NOT NULL, - super_user bit(1) NOT NULL, - CONSTRAINT users_pkey PRIMARY KEY (user_id), - CONSTRAINT users_email UNIQUE (email) - ); - - CREATE SEQUENCE public.organizations_organization_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.organizations - ( - organization_id integer NOT NULL DEFAULT nextval('organizations_organization_id_seq'::regclass), - name character varying(128) COLLATE pg_catalog.default NOT NULL, - CONSTRAINT organizations_pkey PRIMARY KEY (organization_id), - CONSTRAINT organizations_name UNIQUE (name) - ); - - CREATE SEQUENCE public.user_organization_user_organization_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.user_organization - ( - user_organization_id integer NOT NULL DEFAULT nextval('user_organization_user_organization_id_seq'::regclass), - user_id integer NOT NULL, - organization_id integer NOT NULL, - role_id integer NOT NULL, - CONSTRAINT user_organization_pkey PRIMARY KEY (user_organization_id), - CONSTRAINT fk_user_id FOREIGN KEY (user_id) - REFERENCES public.users (user_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) - REFERENCES public.organizations (organization_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_role_id FOREIGN KEY (role_id) - REFERENCES public.roles (role_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SEQUENCE public.rsu_organization_rsu_organization_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.rsu_organization - ( - rsu_organization_id integer NOT NULL DEFAULT nextval('rsu_organization_rsu_organization_id_seq'::regclass), - rsu_id integer NOT NULL, - organization_id integer NOT NULL, - CONSTRAINT rsu_organization_pkey PRIMARY KEY (rsu_organization_id), - CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) - REFERENCES public.rsus (rsu_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) - REFERENCES public.organizations (organization_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE VIEW public.rsu_organization_name AS - SELECT ro.rsu_id, org.name - FROM public.rsu_organization AS ro - JOIN public.organizations AS org ON ro.organization_id = org.organization_id; - - -- Create scms_health table - CREATE SEQUENCE public.scms_health_scms_health_id_seq - INCREMENT 1 - START 1 - MINVALUE 1 - MAXVALUE 2147483647 - CACHE 1; - - CREATE TABLE IF NOT EXISTS public.scms_health - ( - scms_health_id integer NOT NULL DEFAULT nextval('scms_health_scms_health_id_seq'::regclass), - timestamp timestamp without time zone NOT NULL, - health bit(1) NOT NULL, - expiration timestamp without time zone, - rsu_id integer NOT NULL, - CONSTRAINT scms_health_pkey PRIMARY KEY (scms_health_id), - CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) - REFERENCES public.rsus (rsu_id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION - ); - - CREATE SCHEMA IF NOT EXISTS keycloak; diff --git a/resources/kubernetes/rsu-status-checker.yaml b/resources/kubernetes/rsu-status-checker.yaml index f18d0c467..8567154ff 100644 --- a/resources/kubernetes/rsu-status-checker.yaml +++ b/resources/kubernetes/rsu-status-checker.yaml @@ -35,7 +35,7 @@ spec: value: - name: ZABBIX value: - - name: RSU_SNMP_FETCH + - name: RSU_MSGFWD_FETCH value: - name: PG_DB_HOST value: diff --git a/sample-full.env b/sample-full.env new file mode 100644 index 000000000..f9c017699 --- /dev/null +++ b/sample-full.env @@ -0,0 +1,461 @@ +######## ---------------------- DOCKER COMPOSE PROFILES ---------------------- ######## + +# Compose Profiles - see [README](README.md#docker-profiles) and sections below for more information +# There are a number of profiles available to start up groups of services. +# Additionally, each individual service in this project can be started by specifying its service name as a profile. +# The currently available profile groups are listed below. +# basic, webapp, mongo_full, kafka_full, intersection, intersection_no_api, conflictmonitor, addons, obu_ota, kafka_connect_standalone +COMPOSE_PROFILES=basic,webapp,mongo_full,kafka_full,intersection,kafka_connect_standalone + + +######## -------- Required Variables +# run 'ifconfig' in wsl +DOCKER_HOST_IP= + +# Mapbox token for map rendering in the webapp +MAPBOX_TOKEN= + +# GitHub Token (Required for Intersection API) - See services/intersection-api/README.md#github-token for steps to generate +MAVEN_GITHUB_TOKEN= + + +######## -------- General Variables - Apply to All Profiles +KEYCLOAK_DOMAIN=${DOCKER_HOST_IP} +KEYCLOAK_ENDPOINT=http://${KEYCLOAK_DOMAIN}:8084 + +WEBAPP_DOMAIN=localhost +WEBAPP_PORT=3000 +WEBAPP_ENDPOINT=http://${WEBAPP_DOMAIN}:${WEBAPP_PORT} + +API_DOMAIN=${DOCKER_HOST_IP} +API_ENDPOINT=http://${API_DOMAIN}:8081 + +# Remote dockerhub variables for ODE and ConflictMonitor images +DOCKERHUB_HOST=usdotjpoode +DOCKERHUB_RELEASE=2025-q2 + +# Logging Levels - "DEBUG", "INFO", "WARNING", "ERROR" +API_LOGGING_LEVEL="INFO" +FIRMWARE_MANAGER_LOGGING_LEVEL="INFO" +GEO_LOGGING_LEVEL="INFO" +ISS_LOGGING_LEVEL="INFO" +RSU_STATUS_LOGGING_LEVEL="INFO" +COUNTS_LOGGING_LEVEL="INFO" +OBU_OTA_LOGGING_LEVEL="INFO" + +# Also includes "ALL", "FATAL", "OFF", "TRACE" and "WARN" +KC_LOG_LEVEL="INFO" + +# Feature Flags +ENABLE_RSU_FEATURES='true' # 'false' to disable +ENABLE_INTERSECTION_FEATURES='true' # 'false' to disable +ENABLE_WZDX_FEATURES='true' # 'false' to disable +ENABLE_HAAS_FEATURES='true' # 'false' to disable + + +######## -------- "basic" Docker Profile Services +# Run critical cvmanager services +# Requires: None +# Compose file: docker-compose.yml +# Services: +# - cvmanager_api +# - Python backend api for webapp +# - cvmanager_postgres +# - Postgres database for cvmanager data and backing database for keycloak instance +# - cvmanager_keycloak +# - Keycloak instance for user authentication and authorization of webapp and api requests + +#### ---- cvmanager_keycloak +# Keycloak authentication credentials +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin + +# Keycloak Parameters - to generate secret key use a password generator such as: https://www.avast.com/en-us/random-password-generator#pc and set the length to 32 +KEYCLOAK_REALM=cvmanager +KEYCLOAK_GUI_CLIENT_ID=cvmanager-gui +KEYCLOAK_API_CLIENT_ID=cvmanager-api +KEYCLOAK_API_CLIENT_SECRET_KEY=w8zpoArUwIVN6TSDY5WQgX9TlVAgH9OF +KEYCLOAK_LOGIN_THEME_NAME=sample_theme + +# GCP OAuth2.0 client ID for SSO authentication in keycloak - if not specified the google SSO will not be functional +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +#### ---- cvmanager_postgres + +# PostgreSQL Database connection information +# this value may need to follow with the webapp host if debugging the applications +PG_DB_HOST=${DOCKER_HOST_IP}:5432 +PG_DB_NAME=postgres +PG_DB_USER=postgres +# If the PG_DB_PASS variable has special characters, make sure to wrap it in single quotes +PG_DB_PASS=postgres + +#### ---- cvmanager_api + +# Allowed CORS domain for accessing the CV Manager API from (set to the web application hostname) +# Make sure to include http:// or https:// +# If using docker then this value should be set to: http://${WEBAPP_HOST_IP}:3000 +# If running the webapp using npm then set it to: http://localhost:3000 +# Leave as * to allow all domains access +CORS_DOMAIN=* + +# Set these variables if using either "MONGODB" or "BIGQUERY" for COUNT_DESTINATION_DB of jpo_count_metric +MONGO_PROCESSED_BSM_COLLECTION_NAME='ProcessedBsm' +MONGO_PROCESSED_PSM_COLLECTION_NAME='ProcessedPsm' +MONGO_SSM_COLLECTION_NAME= +MONGO_SRM_COLLECTION_NAME= + +# Specifies the maximum number of V2X messages returned from the geo_query_geo_data_mongo method before filtering occurs +MAX_GEO_QUERY_RECORDS= + +# If running firmware manager addon +FIRMWARE_MANAGER_ENDPOINT=http://${DOCKER_HOST_IP}:8089 + +# If connecting to PGDB over websocket: +INSTANCE_CONNECTION_NAME= + +# Python timezone for the CV Manager (You can list pytz timezones with the command 'pytz.all_timezones') +TIMEZONE="US/Mountain" + +# WZDx API key and endpoint for pulling WZDx data into the CV Manager +WZDX_API_KEY= +WZDX_ENDPOINT=data.cotrip.org + +# Error Email Configuration (proxied email sending through the intersection api) +IAPI_ENDPOINT=http://${DOCKER_HOST_IP}:8089 +KEYCLOAK_SA_PYTHON_API_CLIENT_ID=sa_cvmanager_python_api +KEYCLOAK_SA_PYTHON_API_CLIENT_SECRET_KEY=sa-python-api-secret-key + +# Error Email Contact Configuration +LOGS_LINK= #URL to logs for api, included in error email. Example: https://console.cloud.google.com/run/detail/us-central1/rsu-manager-cloud-run-api/logs?authuser=1&project=cdot-oim-cv-dev +ENVIRONMENT_NAME= #Environment name, just to display in email. Example: cdot-oim-cv-dev + +######## -------- "webapp" Docker Profile Services +# Run webapp service for cvmanager +# Requires: basic +# Compose file: docker-compose.yml +# cvmanager_webapp +# - React frontend for cvmanager + +# Mapbox token for map rendering in the webapp +MAPBOX_TOKEN= + +# DOT_NAME must be set for the DOT name to correctly populate when building an image for deployment +DOT_NAME="CDOT" + +# Initial map viewport +MAPBOX_INIT_LATITUDE="39.7392" +MAPBOX_INIT_LONGITUDE="-104.9903" +MAPBOX_INIT_ZOOM="10" + +VIEWER_MSG_TYPES='BSM' + +# Both MUST not have trailing slashes +CVIZ_API_SERVER_URL=http://${DOCKER_HOST_IP}:8089 +CVIZ_API_WS_URL=ws://${DOCKER_HOST_IP}:8089 + +# Webapp themes: dark +# base theme is used by default, dark theme is used if browser is set to dark mode +WEBAPP_THEME_LIGHT="dark" # if not set, defaults to 'dark' +WEBAPP_THEME_DARK="dark" # if not set, defaults to 'dark' + +# Webapp logo to use, imported into docker image as volume. Set the full path to the image, for light and dark mode +WEBAPP_LOGO_PNG_ROOT_FILE_PATH_LIGHT=./webapp/cdot_icon.png +WEBAPP_LOGO_PNG_ROOT_FILE_PATH_DARK=./webapp/cdot_icon.png + + +######## -------- "intersection" Docker Profile Services +# Run connected intersection services +# Requires: basic +# Compose file: docker-compose-intersection.yml +# Services: +# - kafka +# - Message broker for communication between conflictmonitor intersection services +# - kafka_init +# - Initialize kafka topics, then die +# - intersection_api +# - Java backend api for intersection/conflictmonitor services +# - mongodb_container +# - MongoDB database for intersection/conflictmonitor data + +#### ---- intersection_api + +# GitHub Token (Required for Intersection API) - See services/intersection-api/README.md#github-token for steps to generate +MAVEN_GITHUB_TOKEN= +MAVEN_GITHUB_ORG=usdot-jpo-ode + +#Specify MongoDB connection parameters +DB_HOST_IP=${DOCKER_HOST_IP} +DB_HOST_PORT=27017 + +KAFKA_BOOTSTRAP_SERVERS=${DOCKER_HOST_IP}:9092 +KAFKA_BROKER_PORT=9092 + +CM_MAXIMUM_RESPONSE_SIZE=10000 + +CM_SERVER_URL=http://${DOCKER_HOST_IP}:8082 +KAFKA_BROKER_IP=${DOCKER_HOST_IP} + +# Startup delay of intersection_api, to wait for kafka topics to be created by kafka_init +CM_STARTUP_DELAY_SECONDS=90 + +# Enable or Disable Features of the Intersection API, for rest endpoints, notification emailer task, and report generation task +INTERSECTION_API_ENABLE_API=true +INTERSECTION_API_ENABLE_EMAILER=true +INTERSECTION_API_ENABLE_REPORTS=true +INTERSECTION_API_ENABLE_HAAS=true + +# Base Path Prefix - Insert the following path into the base route of all REST endpoints in the Intersection API, used for simplifying proxy routing +# Routes MUST start with a slash +INTERSECTION_API_ROUTE_PREFIX="/" + +# Email Configuration +INTERSECTION_EMAIL_BROKER="" # sendgrid, postmark, anything else will use generic SMTP mail server +INTERSECTION_SENDER_EMAIL= + +# if EMAIL_BROKER is SMTP: +INTERSECTION_SMTP_SERVER_HOST=smtp4dev +INTERSECTION_SMTP_SERVER_PORT=25 +INTERSECTION_SMTP_USERNAME=admin +INTERSECTION_SMTP_PASSWORD=password +INTERSECTION_SMTP_AUTH_ENABLED=true +INTERSECTION_SMTP_STARTTLS_ENABLED=false + +# if EMAIL_BROKER="sendgrid": +SENDGRID_USERNAME= +SENDGRID_PASSWORD= + +# if EMAIL_BROKER="postmark": +POSTMARK_SERVER_TOKEN= + +# Email endpoint rate limiting +EMAIL_RATE_LIMIT_PER_USER=12 # max requests per user/token per hour on /emails/* endpoints +EMAIL_RATE_LIMIT_PER_INSTANCE=120 # max total requests per hour across all users on /emails/* endpoints + +#### ---- mongodb_container + +# The username and password to use for accessing mongoDB. +MONGO_READ_WRITE_USER=ode +MONGO_READ_WRITE_PASS=replace_me + +# Generate a random string for the MongoDB keyfile using the following command: +# $ openssl rand -base64 32 +MONGO_DB_KEYFILE_STRING=replacethisstring + +CM_DATABASE_NAME=CV +MONGO_DATA_RETENTION_SECONDS=31536000 # 1 year +MONGO_DATABASE_MAX_TTL_RETENTION_SECONDS=31536000 # 1 year +# if set to nothing, the import will be skipped +MONGO_SAMPLE_DATA_RELATIVE_PATH=../resources/mongodumps/dump_2025_07_21 + +MONGO_DB_URI="mongodb://${MONGO_READ_WRITE_USER}:${MONGO_READ_WRITE_PASS}@${DB_HOST_IP}:${DB_HOST_PORT}/?directConnection=true&authSource=admin" +MONGO_DB_NAME=${CM_DATABASE_NAME} + + +######## -------- "ADM" Docker Profile Services +ADM_LOG_TO_FILE=false +ADM_LOG_TO_CONSOLE=true +ADM_LOG_LEVEL=INFO + + +######## -------- "AEM" Docker Profile Services +AEM_LOG_TO_FILE=false +AEM_LOG_TO_CONSOLE=true +AEM_LOG_LEVEL=INFO + + +######## -------- "intersection_no_api" Docker Profile Services +# Run connected intersection services without intersection_api +# Requires: basic +# Compose file: docker-compose-intersection.yml +# Services: +# - kafka +# - Message broker for communication between conflictmonitor intersection services +# - kafka_init +# - Initialize kafka topics, then die +# - mongodb_container +# - MongoDB database for intersection/conflictmonitor data + +# No additional variables - see intersection variables + +######## -------- "conflictmonitor" Docker Profile Services +# Run connected conflictmonitor services +# Requires: basic, intersection | intersection_no_api +# Compose file: docker-compose-conflictmonitor.yml +# Services: +# - conflictmonitor +# - Java-based kafka streaming service, generates events, assessments, and notifications from intersection data +# - ode +# - Java-based kafka streaming service, processes raw J2735 messages +# - geojsonconverter +# - Java-based kafka streaming service, generates enhanced geojson-based messages +# - connect +# - Kafka connect service, backs up data on kafka topics to MongoDB + +#### ---- conflictmonitor +RESTART_POLICY="on-failure:3" + +# RocksDB Bounded Memory Config Properties +# 128 MB = 134217728 +# 64 MB = 67108864 +# 16 MB = 16777216 +ROCKSDB_TOTAL_OFF_HEAP_MEMORY=134217728 +ROCKSDB_INDEX_FILTER_BLOCK_RATIO=0.1 +ROCKSDB_TOTAL_MEMTABLE_MEMORY=67108864 +ROCKSDB_BLOCK_SIZE=4096 +ROCKSDB_N_MEMTABLES=2 +ROCKSDB_MEMTABLE_SIZE=16777216 + +######## -------- "deduplicator" Docker Profile services +KAFKA_TOPIC_CREATE_DEDUPLICATOR=true +CONNECT_CREATE_DEDUPLICATOR=true + +#### ---- connect +CONNECT_URL=http://${DOCKER_HOST_IP}:8083 + +######## -------- "addons" Docker Profile Services +# Run all cvmanager helper microservices +# Requires: None +# Compose file: docker-compose-addons.yml +# Services: jpo_count_metric, rsu_status_check, jpo_iss_health_check, firmware_manager_upgrade_scheduler, firmware_manager_upgrade_runner +# - jpo_count_metric +# - Generates counts emails for various data types ("BSM", "TIM", "Map", "SPaT", "SRM", "SSM"). Can store in MongoDB or BigQuery +# - rsu_status_check +# - Checks status of RSUs and stores in Postgres +# - jpo_iss_health_check +# - Retrieves ISS health into and stores in Postgres +# - firmware_manager_upgrade_scheduler +# - Compares RSU firmware versions with Postgres and schedules firmware_manager_upgrade_runner +# - firmware_manager_upgrade_runner +# - Completes RSU firmware upgrades + +#### ---- jpo_count_metric + +# Count Metric Addon: +ENABLE_EMAILER='True' + +# If ENABLE_EMAILER is 'True', set the following environment variables +DEPLOYMENT_TITLE='JPO-ODE' + +# IAPI_ENDPOINT not set here to avoid duplication +KEYCLOAK_SA_COUNT_METRIC_CLIENT_ID=sa_count_metric +KEYCLOAK_SA_COUNT_METRIC_CLIENT_SECRET_KEY=sa-count-metric-secret-key + +# If ENABLE_EMAILER is 'False', set the following environment variables +ODE_KAFKA_BROKERS=${DOCKER_HOST_IP}:9092 + +# EITHER "MONGODB" or "BIGQUERY" +COUNT_DESTINATION_DB='MONGODB' + +# MONGODB REQUIRED VARIABLES +INPUT_COUNTS_MONGO_COLLECTION_NAME='' +OUTPUT_COUNTS_MONGO_COLLECTION_NAME='' + +KAFKA_BIGQUERY_TABLENAME= + +#### ---- rsu_status_check + +# Services that can be toggled on or off +# 'True' or 'False' are the only legal values + +# Toggles monitoring of RSU online status +RSU_PING=True + +# Fetches ping data from Zabbix - alternatively the service will ping the RSUs on its own +# Only used when RSU_PING is 'True' +ZABBIX=False + +# Fetches SNMP configuration data for all RSUs +RSU_MSGFWD_FETCH=True +RSU_SECURITY_FETCH=True +RSU_HEALTH_FETCH=True + +# Zabbix endpoint and API authentication +# Only used when ZABBIX is 'True' +ZABBIX_ENDPOINT= +ZABBIX_USER= +ZABBIX_PASSWORD= + +# Customize the period at which the purger will determine a ping log is too old and will be deleted +# Number of hours +STALE_PERIOD=24 + +#### ---- jpo_iss_health_check + +# Key Storage +## Type of key storage, options: gcp, postgres +STORAGE_TYPE=Postgres + +# If STORAGE_TYPE=gcp +GOOGLE_ACCESS_KEY_NAME=sample_gcp_service_account.json +GCP_PROJECT_ID= + +# ISS Account Authentication +ISS_API_KEY= +ISS_API_KEY_NAME= +ISS_PROJECT_ID= +ISS_SCMS_TOKEN_REST_ENDPOINT= +ISS_SCMS_VEHICLE_REST_ENDPOINT= + +## Postgres Storage (Required if STORAGE_TYPE=postgres) +### Table name to store keys +ISS_KEY_TABLE_NAME= + +#### ---- firmware_manager_upgrade_runner + +BLOB_STORAGE_PROVIDER=DOCKER +BLOB_STORAGE_BUCKET= + +## Docker volume mount point for BLOB storage (if using Docker) +HOST_BLOB_STORAGE_DIRECTORY=./local_blob_storage + +## Maximum retry limit for performing firmware upgrades +FW_UPGRADE_MAX_RETRY_LIMIT=3 + +FIRMWARE_MANAGER_UPGRADE_SCHEDULER_ENDPOINT=http://${DOCKER_HOST_IP}:8089 + +#### ---- firmware_manager_upgrade_scheduler + +FIRMWARE_MANAGER_UPGRADE_RUNNER_ENDPOINT=http://${DOCKER_HOST_IP}:8090 + +# IAPI_ENDPOINT not set here to avoid duplication +KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_ID=sa_firmware_upgrade_runner +KEYCLOAK_SA_FIRMWARE_UPGRADE_RUNNER_CLIENT_SECRET_KEY=sa-firmware-upgrade-runner-secret-key + +######## -------- "obu_ota" Docker Profile Services +# Run OBU over-the-air update microservices +# Requires: None +# Compose file: docker-compose-obu-ota-server.yml +# Services: jpo_ota_backend, jpo_ota_nginx +# - jpo_ota_backend +# - Over-the-air update microservice for OBUs +# - jpo_ota_nginx +# - NGINX proxy for OBU OTA backend + + +#### ---- jpo_ota_backend + +# Route-able hostname for the server +OBU_OTA_SERVER_HOST={DOCKER_HOST_IP} + +# For users using GCP cloud storage +OBU_OTA_BLOB_STORAGE_BUCKET= +OBU_OTA_BLOB_STORAGE_PATH= + +# Nginx basic auth username and password +OTA_USERNAME="admin" +OTA_PASSWORD="admin" + +# Max number of successful firmware upgrades to keep in the database per device SN +MAX_COUNT=10 + +# Nginx encryption options: "plain", "ssl" +# Note that this just changes the config file attached as a volume to the Nginx container +NGINX_ENCRYPTION="plain" + +#### ---- jpo_ota_nginx + +# SSL file name in path /docker/nginx/ssl/ +SERVER_CERT_FILE="ota_server.crt" +SERVER_KEY_FILE="ota_server.key" diff --git a/sample.env b/sample.env index 1361ca207..029be5773 100644 --- a/sample.env +++ b/sample.env @@ -5,453 +5,27 @@ # Additionally, each individual service in this project can be started by specifying its service name as a profile. # The currently available profile groups are listed below. # basic, webapp, mongo_full, kafka_full, intersection, intersection_no_api, conflictmonitor, addons, obu_ota, kafka_connect_standalone -COMPOSE_PROFILES=basic,webapp,mongo_full,kafka_full,intersection,kafka_connect_standalone +COMPOSE_PROFILES=basic,webapp,intersection,mongo_full,kafka_full,kafka_connect_standalone,conflictmonitor -######## -------- General Variables - Apply to All Profiles +######## -------- Required Variables +# run 'ifconfig' in wsl DOCKER_HOST_IP= -KEYCLOAK_DOMAIN=cvmanager.auth.com -KC_HOST_IP=${DOCKER_HOST_IP} -KEYCLOAK_ENDPOINT=http://${KEYCLOAK_DOMAIN}:8084 - -WEBAPP_DOMAIN=cvmanager.local.com -WEBAPP_HOST_IP=${DOCKER_HOST_IP} -WEBAPP_ENDPOINT=http://${WEBAPP_DOMAIN} - -API_ENDPOINT=http://cvmanager.local.com:8081 - -# Remote dockerhub variables for ODE and ConflictMonitor images -DOCKERHUB_HOST=usdotjpoode -DOCKERHUB_RELEASE=2025-q2 - -# Logging Levels - "DEBUG", "INFO", "WARNING", "ERROR" -API_LOGGING_LEVEL="INFO" -FIRMWARE_MANAGER_LOGGING_LEVEL="INFO" -GEO_LOGGING_LEVEL="INFO" -ISS_LOGGING_LEVEL="INFO" -RSU_STATUS_LOGGING_LEVEL="INFO" -COUNTS_LOGGING_LEVEL="INFO" -OBU_OTA_LOGGING_LEVEL="INFO" -KC_LOGGING_LEVEL="INFO" # Also includes "ALL", "FATAL", "OFF", "TRACE" and "WARN" - -# Feature Flags -ENABLE_RSU_FEATURES='true' # 'false' to disable -ENABLE_INTERSECTION_FEATURES='true' # 'false' to disable -ENABLE_WZDX_FEATURES='true' # 'false' to disable -ENABLE_MOOVE_AI_FEATURES='true' # 'false' to disable -ENABLE_HAAS_FEATURES='true' # 'false' to disable - - -######## -------- "basic" Docker Profile Services -# Run critical cvmanager services -# Requires: None -# Compose file: docker-compose.yml -# Services: -# - cvmanager_api -# - Python backend api for webapp -# - cvmanager_postgres -# - Postgres database for cvmanager data and backing database for keycloak instance -# - cvmanager_keycloak -# - Keycloak instance for user authentication and authorization of webapp and api requests - -#### ---- cvmanager_keycloak -# Keycloak authentication credentials -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=admin - -# Keycloak Parameters - to generate secret key use a password generator such as: https://www.avast.com/en-us/random-password-generator#pc and set the length to 32 -KEYCLOAK_REALM=cvmanager -KEYCLOAK_GUI_CLIENT_ID=cvmanager-gui -KEYCLOAK_API_CLIENT_ID=cvmanager-api -KEYCLOAK_API_CLIENT_SECRET_KEY=w8zpoArUwIVN6TSDY5WQgX9TlVAgH9OF -KEYCLOAK_LOGIN_THEME_NAME=sample_theme - -# GCP OAuth2.0 client ID for SSO authentication in keycloak - if not specified the google SSO will not be functional -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -#### ---- cvmanager_postgres - -# PostgreSQL Database connection information -# this value may need to follow with the webapp host if debugging the applications -PG_DB_HOST=${DOCKER_HOST_IP}:5432 -PG_DB_NAME=postgres -PG_DB_USER=postgres -# If the PG_DB_PASS variable has special characters, make sure to wrap it in single quotes -PG_DB_PASS=postgres - -#### ---- cvmanager_api - -# Allowed CORS domain for accessing the CV Manager API from (set to the web application hostname) -# Make sure to include http:// or https:// -# If using docker then this value should be set to: http://${WEBAPP_HOST_IP}:3000 -# If running the webapp using npm then set it to: http://localhost:3000 -# Leave as * to allow all domains access -CORS_DOMAIN=* - -# Set these variables if using either "MONGODB" or "BIGQUERY" for COUNT_DESTINATION_DB of jpo_count_metric -# COUNTS_MSG_TYPES: Comma separated list of message types. -# COUNTS_MSG_TYPES must be set for the counts menu to correctly populate when building an image for deployment -COUNTS_MSG_TYPES='BSM,SSM,SPAT,SRM,MAP' -MONGO_PROCESSED_BSM_COLLECTION_NAME='ProcessedBsm' -MONGO_PROCESSED_PSM_COLLECTION_NAME='ProcessedPsm' -SSM_DB_NAME= -SRM_DB_NAME= - -# Specifies the maximum number of V2X messages returned from the geo_query_geo_data_mongo method before filtering occurs -MAX_GEO_QUERY_RECORDS= - -# If running firmware manager addon -FIRMWARE_MANAGER_ENDPOINT=http://${DOCKER_HOST_IP}:8089 - -# If connecting to PGDB over websocket: -INSTANCE_CONNECTION_NAME= - -# Python timezone for the CV Manager (You can list pytz timezones with the command 'pytz.all_timezones') -TIMEZONE="US/Mountain" - -# WZDx API key and endpoint for pulling WZDx data into the CV Manager -WZDX_API_KEY= -WZDX_ENDPOINT=data.cotrip.org - -# Contact Support Menu Email Configuration -CSM_EMAIL_TO_SEND_FROM= -CSM_EMAIL_APP_USERNAME= -CSM_EMAIL_APP_PASSWORD= -CSM_EMAILS_TO_SEND_TO= -CSM_TARGET_SMTP_SERVER_ADDRESS= -CSM_TARGET_SMTP_SERVER_PORT=587 -CSM_TLS_ENABLED=true -CSM_AUTH_ENABLED=true - -# Error Email Contact Configuration -LOGS_LINK= #URL to logs for api, included in error email. Example: https://console.cloud.google.com/run/detail/us-central1/rsu-manager-cloud-run-api/logs?authuser=1&project=cdot-oim-cv-dev -ENVIRONMENT_NAME= #Environment name, just to display in email. Example: cdot-oim-cv-dev - -# Moove AI feature environment variables -GOOGLE_ACCESS_KEY_NAME=sample_gcp_service_account.json -GCP_PROJECT_ID= -MOOVE_AI_SEGMENT_AGG_STATS_TABLE= -MOOVE_AI_SEGMENT_EVENT_STATS_TABLE= - -######## -------- "webapp" Docker Profile Services -# Run webapp service for cvmanager -# Requires: basic -# Compose file: docker-compose.yml -# cvmanager_webapp -# - React frontend for cvmanager - # Mapbox token for map rendering in the webapp MAPBOX_TOKEN= -# DOT_NAME must be set for the DOT name to correctly populate when building an image for deployment -DOT_NAME="CDOT" - -# Initial map viewport -MAPBOX_INIT_LATITUDE="39.7392" -MAPBOX_INIT_LONGITUDE="-104.9903" -MAPBOX_INIT_ZOOM="10" - -VIEWER_MSG_TYPES='BSM' - -# Both MUST not have trailing slashes -CVIZ_API_SERVER_URL=http://${DOCKER_HOST_IP}:8089 -CVIZ_API_WS_URL=ws://${DOCKER_HOST_IP}:8089 - -# Webapp themes: dark -# base theme is used by default, dark theme is used if browser is set to dark mode -WEBAPP_THEME_LIGHT="dark" # if not set, defaults to 'dark' -WEBAPP_THEME_DARK="dark" # if not set, defaults to 'dark' - -# Webapp logo to use, imported into docker image as volume. Set the full path to the image, for light and dark mode -WEBAPP_LOGO_PNG_ROOT_FILE_PATH_LIGHT=./webapp/cdot_icon.png -WEBAPP_LOGO_PNG_ROOT_FILE_PATH_DARK=./webapp/cdot_icon.png - - -######## -------- "intersection" Docker Profile Services -# Run connected intersection services -# Requires: basic -# Compose file: docker-compose-intersection.yml -# Services: -# - kafka -# - Message broker for communication between conflictmonitor intersection services -# - kafka_init -# - Initialize kafka topics, then die -# - intersection_api -# - Java backend api for intersection/conflictmonitor services -# - mongodb_container -# - MongoDB database for intersection/conflictmonitor data - -#### ---- intersection_api - # GitHub Token (Required for Intersection API) - See services/intersection-api/README.md#github-token for steps to generate MAVEN_GITHUB_TOKEN= -MAVEN_GITHUB_ORG=usdot-jpo-ode - -#Specify MongoDB connection parameters -DB_HOST_IP=${DOCKER_HOST_IP} -DB_HOST_PORT=27017 - -KAFKA_BOOTSTRAP_SERVERS=${DOCKER_HOST_IP}:9092 -KAFKA_BROKER_PORT=9092 - -CM_MAXIMUM_RESPONSE_SIZE=10000 - -CM_SERVER_URL=http://${DOCKER_HOST_IP}:8082 -KAFKA_BROKER_IP=${DOCKER_HOST_IP} - -# Startup delay of intersection_api, to wait for kafka topics to be created by kafka_init -CM_STARTUP_DELAY_SECONDS=90 - -# Enable or Disable Features of the Intersection API, for rest endpoints, notification emailer task, and report generation task -INTERSECTION_API_ENABLE_API=true -INTERSECTION_API_ENABLE_EMAILER=true -INTERSECTION_API_ENABLE_REPORTS=true -INTERSECTION_API_ENABLE_HAAS=true - -# Base Path Prefix - Insert the following path into the base route of all REST endpoints in the Intersection API, used for simplifying proxy routing -# Routes MUST start with a slash -INTERSECTION_API_ROUTE_PREFIX="/" - -# ASN.1 FFM Decoder Config -J2735_ASN_TEXT_BUFFER_SIZE=262144 -J2735_UPER_BUFFER_SIZE=8192 -J2735_ERROR_BUFFER_SIZE=256 - -# Email Configuration -INTERSECTION_EMAIL_BROKER="" # sendgrid, postmark, anything else will use generic SMTP mail server - -# if EMAIL_BROKER is not set (SMTP): -INTERSECTION_SENDER_EMAIL= -INTERSECTION_SMTP_SERVER_IP= -INTERSECTION_SMTP_SERVER_PORT=1025 - -# if EMAIL_BROKER="sendgrid": -SENDGRID_USERNAME= -SENDGRID_PASSWORD= - -# if EMAIL_BROKER="postmark": -POSTMARK_SERVER_TOKEN= - -#### ---- mongodb_container - -# The username and password to use for accessing mongoDB. -MONGO_READ_WRITE_USER=ode -MONGO_READ_WRITE_PASS=replace_me -# Generate a random string for the MongoDB keyfile using the following command: -# $ openssl rand -base64 32 -MONGO_DB_KEYFILE_STRING=replacethisstring -CM_DATABASE_NAME=CV -MONGO_DATA_RETENTION_SECONDS=31536000 # 1 year -MONGO_DATABASE_MAX_TTL_RETENTION_SECONDS=31536000 # 1 year -# if set to nothing, the import will be skipped -MONGO_SAMPLE_DATA_RELATIVE_PATH=../resources/mongodumps/dump_2025_07_21 +######## -------- Optional (but recommended) Variables -MONGO_DB_URI="mongodb://${MONGO_READ_WRITE_USER}:${MONGO_READ_WRITE_PASS}@${DB_HOST_IP}:${DB_HOST_PORT}/?directConnection=true&authSource=admin" -MONGO_DB_NAME=${CM_DATABASE_NAME} +# Load sample intersection data +MONGO_SAMPLE_DATA_RELATIVE_PATH=../resources/mongodumps/dump_2025_10_15 - -######## -------- "ADM" Docker Profile Services -ADM_LOG_TO_FILE=false -ADM_LOG_TO_CONSOLE=true -ADM_LOG_LEVEL=INFO - - -######## -------- "AEM" Docker Profile Services -AEM_LOG_TO_FILE=false -AEM_LOG_TO_CONSOLE=true -AEM_LOG_LEVEL=INFO - - -######## -------- "intersection_no_api" Docker Profile Services -# Run connected intersection services without intersection_api -# Requires: basic -# Compose file: docker-compose-intersection.yml -# Services: -# - kafka -# - Message broker for communication between conflictmonitor intersection services -# - kafka_init -# - Initialize kafka topics, then die -# - mongodb_container -# - MongoDB database for intersection/conflictmonitor data - -# No additional variables - see intersection variables - -######## -------- "conflictmonitor" Docker Profile Services -# Run connected conflictmonitor services -# Requires: basic, intersection | intersection_no_api -# Compose file: docker-compose-conflictmonitor.yml -# Services: -# - conflictmonitor -# - Java-based kafka streaming service, generates events, assessments, and notifications from intersection data -# - ode -# - Java-based kafka streaming service, processes raw J2735 messages -# - geojsonconverter -# - Java-based kafka streaming service, generates enhanced geojson-based messages -# - connect -# - Kafka connect service, backs up data on kafka topics to MongoDB - -#### ---- conflictmonitor -RESTART_POLICY="on-failure:3" - -# RocksDB Bounded Memory Config Properties -# 128 MB = 134217728 -# 64 MB = 67108864 -# 16 MB = 16777216 -ROCKSDB_TOTAL_OFF_HEAP_MEMORY=134217728 -ROCKSDB_INDEX_FILTER_BLOCK_RATIO=0.1 -ROCKSDB_TOTAL_MEMTABLE_MEMORY=67108864 -ROCKSDB_BLOCK_SIZE=4096 -ROCKSDB_N_MEMTABLES=2 -ROCKSDB_MEMTABLE_SIZE=16777216 - -######## -------- "deduplicator" Docker Profile services -KAFKA_TOPIC_CREATE_DEDUPLICATOR=true -CONNECT_CREATE_DEDUPLICATOR=true - -#### ---- connect -CONNECT_URL=http://${DOCKER_HOST_IP}:8083 - -######## -------- "addons" Docker Profile Services -# Run all cvmanager helper microservices -# Requires: None -# Compose file: docker-compose-addons.yml -# Services: jpo_count_metric, rsu_status_check, jpo_iss_health_check, firmware_manager_upgrade_scheduler, firmware_manager_upgrade_runner -# - jpo_count_metric -# - Generates counts emails for various data types ("BSM", "TIM", "Map", "SPaT", "SRM", "SSM"). Can store in MongoDB or BigQuery -# - rsu_status_check -# - Checks status of RSUs and stores in Postgres -# - jpo_iss_health_check -# - Retrieves ISS health into and stores in Postgres -# - firmware_manager_upgrade_scheduler -# - Compares RSU firmware versions with Postgres and schedules firmware_manager_upgrade_runner -# - firmware_manager_upgrade_runner -# - Completes RSU firmware upgrades - -#### ---- jpo_count_metric - -# Count Metric Addon: -ENABLE_EMAILER='True' - -# If ENABLE_EMAILER is 'True', set the following environment variables -DEPLOYMENT_TITLE='JPO-ODE' - -# SMTP REQUIRED VARIABLES -SMTP_SERVER_IP='' -SMTP_USERNAME='' -SMTP_PASSWORD='' -SMTP_EMAIL='' - -# If ENABLE_EMAILER is 'False', set the following environment variables -COUNT_MESSAGE_TYPES='bsm' -ODE_KAFKA_BROKERS=${DOCKER_HOST_IP}:9092 - -# EITHER "MONGODB" or "BIGQUERY" -COUNT_DESTINATION_DB='MONGODB' - -# MONGODB REQUIRED VARIABLES -INPUT_COUNTS_MONGO_COLLECTION_NAME='' -OUTPUT_COUNTS_MONGO_COLLECTION_NAME='' - -KAFKA_BIGQUERY_TABLENAME= - -#### ---- rsu_status_check - -# Services that can be toggled on or off -# 'True' or 'False' are the only legal values - -# Toggles monitoring of RSU online status -RSU_PING=True - -# Fetches ping data from Zabbix - alternatively the service will ping the RSUs on its own -# Only used when RSU_PING is 'True' -ZABBIX=False - -# Fetches SNMP configuration data for all RSUs -RSU_MSGFWD_FETCH=True -RSU_SECURITY_FETCH=True -RSU_HEALTH_FETCH=True - -# Zabbix endpoint and API authentication -# Only used when ZABBIX is 'True' -ZABBIX_ENDPOINT= -ZABBIX_USER= -ZABBIX_PASSWORD= - -# Customize the period at which the purger will determine a ping log is too old and will be deleted -# Number of hours -STALE_PERIOD=24 - -#### ---- jpo_iss_health_check - -# Key Storage -## Type of key storage, options: gcp, postgres -STORAGE_TYPE=postgres - -# ISS Account Authentication -ISS_API_KEY= -ISS_API_KEY_NAME= -ISS_PROJECT_ID= -ISS_SCMS_TOKEN_REST_ENDPOINT= -ISS_SCMS_VEHICLE_REST_ENDPOINT= - -## Postgres Storage (Required if STORAGE_TYPE=postgres) -### Table name to store keys -ISS_KEY_TABLE_NAME= - -#### ---- firmware_manager_upgrade_runner - -BLOB_STORAGE_PROVIDER=DOCKER -BLOB_STORAGE_BUCKET= - -## Docker volume mount point for BLOB storage (if using Docker) -HOST_BLOB_STORAGE_DIRECTORY=./local_blob_storage - -## Maximum retry limit for performing firmware upgrades -FW_UPGRADE_MAX_RETRY_LIMIT=3 - -FIRMWARE_MANAGER_UPGRADE_SCHEDULER_ENDPOINT=http://${DOCKER_HOST_IP}:8089 - -#### ---- firmware_manager_upgrade_scheduler - -FIRMWARE_MANAGER_UPGRADE_RUNNER_ENDPOINT=http://${DOCKER_HOST_IP}:8090 - -######## -------- "obu_ota" Docker Profile Services -# Run OBU over-the-air update microservices -# Requires: None -# Compose file: docker-compose-obu-ota-server.yml -# Services: jpo_ota_backend, jpo_ota_nginx -# - jpo_ota_backend -# - Over-the-air update microservice for OBUs -# - jpo_ota_nginx -# - NGINX proxy for OBU OTA backend - - -#### ---- jpo_ota_backend - -# Route-able hostname for the server -OBU_OTA_SERVER_HOST={DOCKER_HOST_IP} - -# For users using GCP cloud storage -OBU_OTA_BLOB_STORAGE_BUCKET= -OBU_OTA_BLOB_STORAGE_PATH= - -# Nginx basic auth username and password -OTA_USERNAME="admin" -OTA_PASSWORD="admin" - -# Max number of successful firmware upgrades to keep in the database per device SN -MAX_COUNT=10 - -# Nginx encryption options: "plain", "ssl" -# Note that this just changes the config file attached as a volume to the Nginx container -NGINX_ENCRYPTION="plain" - -#### ---- jpo_ota_nginx - -# SSL file name in path /docker/nginx/ssl/ -SERVER_CERT_FILE="ota_server.crt" -SERVER_KEY_FILE="ota_server.key" +# WZDx API key and endpoint for pulling WZDx data into the CV Manager (See https://maps.cotrip.org/help/117/Traveler-Information-Data-Feed-Access to generate an API key) +# Make sure to uncomment the next line if setting the WZDx api key +# ENABLE_WZDX_FEATURES='true' +WZDX_API_KEY= +WZDX_ENDPOINT=data.cotrip.org \ No newline at end of file diff --git a/services/Dockerfile.intersection-api b/services/Dockerfile.intersection-api index 25d878d42..d26df32af 100644 --- a/services/Dockerfile.intersection-api +++ b/services/Dockerfile.intersection-api @@ -24,6 +24,9 @@ FROM eclipse-temurin:22-jre-noble WORKDIR /home +ENV EMAIL_RATE_LIMIT_PER_USER=12 +ENV EMAIL_RATE_LIMIT_PER_INSTANCE=120 + COPY --from=builder /home/intersection-api/src/main/resources/application.yaml /home COPY --from=builder /home/intersection-api/target/intersection-api.jar /home diff --git a/services/addons/images/count_metric/count_metric_environment.py b/services/addons/images/count_metric/count_metric_environment.py new file mode 100644 index 000000000..0379863e3 --- /dev/null +++ b/services/addons/images/count_metric/count_metric_environment.py @@ -0,0 +1,10 @@ +from common.common_environment import get_env_var + +DEPLOYMENT_TITLE = get_env_var("DEPLOYMENT_TITLE", "Example Deployment", warn=True) +MONGO_DB_URI = get_env_var("MONGO_DB_URI", "mongodb://localhost:27017") +MONGO_DB_NAME = get_env_var("MONGO_DB_NAME", "CV") +IAPI_ENDPOINT = get_env_var("IAPI_ENDPOINT", error=True) +KC_ENDPOINT = get_env_var("KC_ENDPOINT", error=True) +KC_REALM = get_env_var("KC_REALM", error=True) +KC_SA_CLIENT_ID = get_env_var("KC_SA_CLIENT_ID", "sa_count_metric") +KC_SA_CLIENT_SECRET = get_env_var("KC_SA_CLIENT_SECRET", error=True, secret=True) diff --git a/services/addons/images/count_metric/daily_emailer.py b/services/addons/images/count_metric/daily_emailer.py index 86e98e83c..ae3ab6b1b 100644 --- a/services/addons/images/count_metric/daily_emailer.py +++ b/services/addons/images/count_metric/daily_emailer.py @@ -1,11 +1,10 @@ -import os import logging -import gen_email -from common.emailSender import EmailSender +from common.email_api import EmailApi import common.pgquery as pgquery -from common.email_util import get_email_list from datetime import datetime, timedelta from pymongo import MongoClient +import count_metric_environment +from common.keycloak_api import KeycloakServiceAccountApi message_types = ["BSM", "TIM", "Map", "SPaT", "SRM", "SSM"] @@ -122,33 +121,41 @@ def prepare_org_rsu_dict(): return rsu_dict -def email_daily_counts(org_name, email_body): +def email_daily_counts( + org_name: str, + deployment_title: str, + start_date: datetime, + end_date: datetime, + message_type_list: list[str], + counts: list[dict], +): logging.info("Attempting to send the count emails...") try: - email_addresses = get_email_list("Daily Message Counts", org_name) - - for email_address in email_addresses: - emailSender = EmailSender( - os.environ["SMTP_SERVER_IP"], - 587, - ) - emailSender.send( - sender=os.environ["SMTP_EMAIL"], - recipient=email_address, - subject=f"{org_name} {str(os.environ['DEPLOYMENT_TITLE'])} Counts", - message=email_body, - replyEmail="", - username=os.environ["SMTP_USERNAME"], - password=os.environ["SMTP_PASSWORD"], - pretty=True, - ) + kc_api = KeycloakServiceAccountApi( + endpoint=count_metric_environment.KC_ENDPOINT, + realm=count_metric_environment.KC_REALM, + client_id=count_metric_environment.KC_SA_CLIENT_ID, + client_secret=count_metric_environment.KC_SA_CLIENT_SECRET, + ) + email_api = EmailApi( + iapi_base_url=count_metric_environment.IAPI_ENDPOINT, kc_api=kc_api + ) + + email_api.send_message_counts( + org_name, + deployment_title, + start_date, + end_date, + message_type_list, + counts, + ) except Exception as e: logging.error(e) def run_daily_emailer(): - client = MongoClient(os.getenv("MONGO_DB_URI")) - mongo_db = client[os.getenv("MONGO_DB_NAME")] + client = MongoClient(count_metric_environment.MONGO_DB_URI) + mongo_db = client[count_metric_environment.MONGO_DB_NAME] # Grab today's date and yesterday's date for a 24 hour range start_dt = (datetime.now() - timedelta(1)).replace( @@ -164,11 +171,24 @@ def run_daily_emailer(): query_mongo_in_counts(rsu_dict, start_dt, end_dt, mongo_db) query_mongo_out_counts(rsu_dict, start_dt, end_dt, mongo_db) - # Generate the email content with the populated rsu_dict - email_body = gen_email.generate_email_body( - org_name, rsu_dict, start_dt, end_dt, message_types + rsu_counts = [ + { + "rsu_ip": rsu_ip, + "counts": data["counts"], + "primary_route": data["primary_route"], + } + for rsu_ip, data in rsu_dict.items() + ] + + # Send emails through the Intersection API + email_daily_counts( + org_name=org_name, + deployment_title=count_metric_environment.DEPLOYMENT_TITLE, + start_date=start_dt, + end_date=end_dt, + message_type_list=message_types, + counts=rsu_counts, ) - email_daily_counts(org_name, email_body) if __name__ == "__main__": diff --git a/services/addons/images/count_metric/gen_email.py b/services/addons/images/count_metric/gen_email.py index b0da6058b..fb56fb0a8 100644 --- a/services/addons/images/count_metric/gen_email.py +++ b/services/addons/images/count_metric/gen_email.py @@ -1,6 +1,6 @@ import logging -import os from datetime import datetime +import count_metric_environment def diff_to_color(val): @@ -39,7 +39,7 @@ def generate_table_row(rsu_ip, data, row_style, message_type_list): def generate_count_table(rsu_dict, message_type_list): - logging.info(f"Creating count table...") + logging.info("Creating count table...") # If the RSU dictionary is completely empty, return nothing to indicate an issue has occurred somewhere if not rsu_dict: @@ -97,7 +97,7 @@ def generate_email_body(org_name, rsu_dict, start_dt, end_dt, message_type_list) # DEPLOYMENT_TITLE is a contextual title for where these counts apply. ie. "GCP prod" # This is generalized to support any deployment environment html = ( - f'

{org_name} {str(os.environ["DEPLOYMENT_TITLE"])} Count Report {start} UTC - {end} UTC

' + f"

{org_name} {count_metric_environment.DEPLOYMENT_TITLE} Count Report {start} UTC - {end} UTC

" "

This is an automated email to report yesterday's ODE message counts for J2735 messages going in and out of the ODE. " "In counts are the number of encoded messages received by the ODE from the load balancer. " "Out counts are the number of decoded messages that have come out of the ODE in JSON form and " diff --git a/services/addons/images/count_metric/mongo_counter.py b/services/addons/images/count_metric/mongo_counter.py index 1f99711ea..c05b981ae 100644 --- a/services/addons/images/count_metric/mongo_counter.py +++ b/services/addons/images/count_metric/mongo_counter.py @@ -1,7 +1,7 @@ -import os import logging from pymongo import MongoClient from datetime import datetime, timedelta +import count_metric_environment message_types = ["BSM", "TIM", "Map", "SPaT", "SRM", "SSM", "PSM"] @@ -79,7 +79,7 @@ def run_mongo_counter(mongo_db): if __name__ == "__main__": logging.info("Starting the MongoDB counter") - client = MongoClient(os.getenv("MONGO_DB_URI")) - mongo_db = client[os.getenv("MONGO_DB_NAME")] + client = MongoClient(count_metric_environment.MONGO_DB_URI) + mongo_db = client[count_metric_environment.MONGO_DB_NAME] run_mongo_counter(mongo_db) logging.info("MongoDB counter has finished") diff --git a/services/addons/images/count_metric/requirements.txt b/services/addons/images/count_metric/requirements.txt index 5ed19ebd2..ce79543dd 100644 --- a/services/addons/images/count_metric/requirements.txt +++ b/services/addons/images/count_metric/requirements.txt @@ -4,3 +4,4 @@ pymongo==4.5.0 sqlalchemy==2.0.21 pg8000==1.30.2 python-dateutil==2.8.2 +python-keycloak==5.8.1 diff --git a/services/addons/images/count_metric/sample.env b/services/addons/images/count_metric/sample.env index 4927c0ff9..63673c35a 100644 --- a/services/addons/images/count_metric/sample.env +++ b/services/addons/images/count_metric/sample.env @@ -16,16 +16,14 @@ PG_DB_NAME = MONGO_DB_URI = 'mongodb://:27017/' MONGO_DB_NAME = '' -# SMTP REQUIRED VARIABLES -SMTP_SERVER_IP = '' -SMTP_USERNAME = '' -SMTP_PASSWORD = '' -SMTP_EMAIL = '' +# Error Email Configuration (proxied email sending through the intersection api) +IAPI_ENDPOINT=http://${DOCKER_HOST_IP}:8089 +KC_SA_CLIENT_ID=sa_count_metric +KC_SA_CLIENT_SECRET=sa-count-metric-secret-key # --------------------------------------------------------------------- # If ENABLE_EMAILER is 'False', set the following environment variables -MESSAGE_TYPES = 'bsm' PROJECT_ID = '' ODE_KAFKA_BROKERS = ':9092' diff --git a/services/addons/images/firmware_manager/requirements.txt b/services/addons/images/firmware_manager/requirements.txt index c782fda46..50c160148 100644 --- a/services/addons/images/firmware_manager/requirements.txt +++ b/services/addons/images/firmware_manager/requirements.txt @@ -10,3 +10,4 @@ sqlalchemy==2.0.21 waitress==2.1.2 python-dateutil==2.8.2 pytz==2023.3.post1 +python-keycloak==5.8.1 \ No newline at end of file diff --git a/services/addons/images/firmware_manager/upgrade_runner/commsignia_upgrader.py b/services/addons/images/firmware_manager/upgrade_runner/commsignia_upgrader.py index b4f13191e..dec3773e8 100644 --- a/services/addons/images/firmware_manager/upgrade_runner/commsignia_upgrader.py +++ b/services/addons/images/firmware_manager/upgrade_runner/commsignia_upgrader.py @@ -1,11 +1,12 @@ import time +import traceback from paramiko import SSHClient, WarningPolicy from scp import SCPClient import upgrader import json import logging -import os import sys +from common import common_environment class CommsigniaUpgrader(upgrader.UpgraderAbstractClass): @@ -74,7 +75,10 @@ def upgrade(self): self.cleanup() self.notify_firmware_manager(success=False) # send email to support team with the rsu and error - self.send_error_email("Firmware Upgrader", err) + stack_trace = traceback.format_exc() + self.send_error_email( + str(err), stack_trace, "Commsignia Firmware Upgrade Error" + ) def post_upgrade(self): if self.wait_until_online() == -1: @@ -103,8 +107,8 @@ def post_upgrade(self): # Change permissions and execute post upgrade script logging.info("Running post upgrade script for " + self.rsu_ip + "...") - ssh.exec_command(f"chmod +x /tmp/post_upgrade.sh") - _stdin, _stdout, _stderr = ssh.exec_command(f"/tmp/post_upgrade.sh") + ssh.exec_command("chmod +x /tmp/post_upgrade.sh") + _stdin, _stdout, _stderr = ssh.exec_command("/tmp/post_upgrade.sh") decoded_stdout = _stdout.read().decode() logging.info(decoded_stdout) if "ALL OK" not in decoded_stdout: @@ -122,7 +126,10 @@ def post_upgrade(self): f"Failed to execute post upgrade script for rsu {self.rsu_ip}: {err}" ) # send email to support team with the rsu and error - self.send_error_email("Post-Upgrade Script", err) + stack_trace = traceback.format_exc() + self.send_error_email( + str(err), stack_trace, "Commsignia Post-Upgrade Script Error" + ) # sys.argv[1] - JSON string with the following key-values: @@ -135,8 +142,10 @@ def post_upgrade(self): # - target_firmware_version # - install_package if __name__ == "__main__": - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) + logging.info( + "Commsignia upgrader running with LOGGING_LEVEL: " + + str(common_environment.LOGGING_LEVEL) + ) # Trimming outer single quotes from the json.loads upgrade_info = json.loads(sys.argv[1][1:-1]) commsignia_upgrader = CommsigniaUpgrader(upgrade_info) diff --git a/services/addons/images/firmware_manager/upgrade_runner/sample.env b/services/addons/images/firmware_manager/upgrade_runner/sample.env index 0b68da477..87372f1c1 100644 --- a/services/addons/images/firmware_manager/upgrade_runner/sample.env +++ b/services/addons/images/firmware_manager/upgrade_runner/sample.env @@ -13,11 +13,10 @@ FW_UPGRADE_MAX_RETRY_LIMIT=3 GCP_PROJECT="" GOOGLE_APPLICATION_CREDENTIALS="" -# For sending failure emails -SMTP_SERVER_IP= -SMTP_EMAIL= -SMTP_USERNAME= -SMTP_PASSWORD= +# Error Email Configuration (proxied email sending through the intersection api) +IAPI_ENDPOINT=http://${DOCKER_HOST_IP}:8089 +KC_SA_CLIENT_ID=sa_firmware_upgrade_runner +KC_SA_CLIENT_SECRET=sa-firmware-upgrade-runner-secret-key # Must specify this endpoint to wherever the Upgrade Scheduler is hosted # Must include 'http://' or 'https://' along with specified port if non-standard diff --git a/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner.py b/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner.py index 027ffc53d..a15467ef4 100644 --- a/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner.py +++ b/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner.py @@ -4,13 +4,10 @@ from marshmallow import Schema, fields import json import logging -import os +from common import common_environment app = Flask(__name__) -log_level = os.environ.get("LOGGING_LEVEL", "INFO") -logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - manufacturer_upgrade_scripts = { "Commsignia": "commsignia_upgrader.py", "Yunex": "yunex_upgrader.py", @@ -96,4 +93,8 @@ def serve_rest_api(): if __name__ == "__main__": + logging.info( + "Firmware manager running with LOGGING_LEVEL: " + + str(common_environment.LOGGING_LEVEL) + ) serve_rest_api() diff --git a/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner_environment.py b/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner_environment.py new file mode 100644 index 000000000..2532797e7 --- /dev/null +++ b/services/addons/images/firmware_manager/upgrade_runner/upgrade_runner_environment.py @@ -0,0 +1,10 @@ +from common.common_environment import get_env_var + +BLOB_STORAGE_PROVIDER = get_env_var("BLOB_STORAGE_PROVIDER", "DOCKER", warn=False) +UPGRADE_SCHEDULER_ENDPOINT = get_env_var("UPGRADE_SCHEDULER_ENDPOINT", "127.0.0.1") + +IAPI_ENDPOINT = get_env_var("IAPI_ENDPOINT", error=True) +KC_ENDPOINT = get_env_var("KC_ENDPOINT", error=True) +KC_REALM = get_env_var("KC_REALM", error=True) +KC_SA_CLIENT_ID = get_env_var("KC_SA_CLIENT_ID", "sa_firmware_upgrade_runner") +KC_SA_CLIENT_SECRET = get_env_var("KC_SA_CLIENT_SECRET", error=True, secret=True) diff --git a/services/addons/images/firmware_manager/upgrade_runner/upgrader.py b/services/addons/images/firmware_manager/upgrade_runner/upgrader.py index fb3dd24e5..d8e5c1a01 100644 --- a/services/addons/images/firmware_manager/upgrade_runner/upgrader.py +++ b/services/addons/images/firmware_manager/upgrade_runner/upgrader.py @@ -4,12 +4,12 @@ import time from common import gcs_utils import logging -import os import requests import shutil -from common.emailSender import EmailSender -from common.email_util import get_email_list_from_rsu -import download_blob +from common.email_api import EmailApi +from common.keycloak_api import KeycloakServiceAccountApi +from addons.images.firmware_manager.upgrade_runner import download_blob +import upgrade_runner_environment class UpgraderAbstractClass(abc.ABC): @@ -45,9 +45,7 @@ def download_blob( ) # Download blob, defaults to GCP blob storage - bspCaseInsensitive = os.environ.get( - "BLOB_STORAGE_PROVIDER", "DOCKER" - ).casefold() + bspCaseInsensitive = upgrade_runner_environment.BLOB_STORAGE_PROVIDER.casefold() if bspCaseInsensitive == "gcp": return ( gcs_utils.download_gcp_blob( @@ -73,8 +71,8 @@ def notify_firmware_manager(self, success): ) # Obtain the upgrade scheduler endpoint - upgrade_scheduler_endpoint = os.environ.get( - "UPGRADE_SCHEDULER_ENDPOINT", "127.0.0.1" + upgrade_scheduler_endpoint = ( + upgrade_runner_environment.UPGRADE_SCHEDULER_ENDPOINT ) if upgrade_scheduler_endpoint == "UNDEFINED": raise Exception( @@ -118,33 +116,24 @@ def check_online(self): # 5 seconds pass with no response return False - def send_error_email(self, type="Firmware Upgrader", err=""): + def send_error_email(self, err: Exception, stack_trace: str, type: str): try: - email_addresses = get_email_list_from_rsu( - "Firmware Upgrade Failures", self.rsu_ip + kc_api = KeycloakServiceAccountApi( + endpoint=upgrade_runner_environment.KC_ENDPOINT, + realm=upgrade_runner_environment.KC_REALM, + client_id=upgrade_runner_environment.KC_SA_CLIENT_ID, + client_secret=upgrade_runner_environment.KC_SA_CLIENT_SECRET, ) - - subject = ( - f"{self.rsu_ip} Firmware Upgrader Failure" - if type == "Firmware Upgrader" - else f"{self.rsu_ip} Firmware Upgrader Post Upgrade Script Failure" + email_api = EmailApi( + iapi_base_url=upgrade_runner_environment.IAPI_ENDPOINT, kc_api=kc_api ) - for email_address in email_addresses: - emailSender = EmailSender( - os.environ["SMTP_SERVER_IP"], - 587, - ) - emailSender.send( - sender=os.environ["SMTP_EMAIL"], - recipient=email_address, - subject=subject, - message=f"{type}: Failed to perform update on RSU {self.rsu_ip} due to the following error: {err}", - replyEmail="", - username=os.environ["SMTP_USERNAME"], - password=os.environ["SMTP_PASSWORD"], - pretty=True, - ) + email_api.send_firmware_upgrade_failure( + rsu_ip=self.rsu_ip, + error_message=f"{type}: Failed to perform update on RSU {self.rsu_ip} due to the following error: {err}", + failure_type=type, + stack_trace=stack_trace, + ) except Exception as e: logging.error(e) diff --git a/services/addons/images/firmware_manager/upgrade_runner/yunex_upgrader.py b/services/addons/images/firmware_manager/upgrade_runner/yunex_upgrader.py index 517a5764d..a9133771c 100644 --- a/services/addons/images/firmware_manager/upgrade_runner/yunex_upgrader.py +++ b/services/addons/images/firmware_manager/upgrade_runner/yunex_upgrader.py @@ -1,11 +1,12 @@ import upgrader import json import logging -import os import subprocess import sys import tarfile import time +import traceback +from common import common_environment class YunexUpgrader(upgrader.UpgraderAbstractClass): @@ -16,7 +17,7 @@ def run_xfer_upgrade(self, file_name): xfer_command = [ "java", "-jar", - f"/home/tools/xfer_yunex.jar", + "/home/tools/xfer_yunex.jar", "-upload", file_name, f"{self.rsu_ip}:3600", @@ -111,7 +112,8 @@ def upgrade(self): self.cleanup() self.notify_firmware_manager(success=False) # send email to support team with the rsu and error - self.send_error_email("Firmware Upgrader", err) + stack_trace = traceback.format_exc() + self.send_error_email(str(err), stack_trace, "Yunex Firmware Upgrade Error") # sys.argv[1] - JSON string with the following key-values: @@ -124,8 +126,10 @@ def upgrade(self): # - target_firmware_version # - install_package if __name__ == "__main__": - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) + logging.info( + "Yunex Upgrader running with LOGGING_LEVEL: " + + str(common_environment.LOGGING_LEVEL) + ) # Trimming outer single quotes from the json.loads upgrade_info = json.loads(sys.argv[1][1:-1]) yunex_upgrader = YunexUpgrader(upgrade_info) diff --git a/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler.py b/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler.py index 3b965c5c9..2c5f6ce90 100644 --- a/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler.py +++ b/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler.py @@ -6,14 +6,10 @@ from waitress import serve import requests import logging -import os +import upgrade_scheduler_environment app = Flask(__name__) -log_level = os.environ.get("LOGGING_LEVEL", "INFO") -logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - - # Tracker for active firmware upgrades # Key: IPv4 string of target device # Value: Dictionary with the following key-values: @@ -33,7 +29,7 @@ # Changed from a constant to a function to help with unit testing def get_upgrade_limit() -> int: try: - upgrade_limit = int(os.environ.get("ACTIVE_UPGRADE_LIMIT", "1")) + upgrade_limit = int(upgrade_scheduler_environment.ACTIVE_UPGRADE_LIMIT) return upgrade_limit except ValueError: raise ValueError( @@ -41,7 +37,6 @@ def get_upgrade_limit() -> int: ) - # Function to query the CV Manager PostgreSQL database for RSUs that have: # - A different target version than their current version # - A target firmware that complies with an existing upgrade rule relative to the RSU's current version @@ -74,26 +69,37 @@ def get_rsu_upgrade_data(rsu_ip="all"): def start_tasks_from_queue(): # Start the next process in the queue if there are less than ACTIVE_UPGRADE_LIMIT number of active upgrades occurring - while len(active_upgrades) < get_upgrade_limit() and len(upgrade_queue) > 0: + while ( + len(active_upgrades) < upgrade_scheduler_environment.ACTIVE_UPGRADE_LIMIT + and len(upgrade_queue) > 0 + ): rsu_to_upgrade = upgrade_queue.popleft() try: rsu_upgrade_info = upgrade_queue_info[rsu_to_upgrade] del upgrade_queue_info[rsu_to_upgrade] # Begin the firmware upgrade using the Upgrade Runner API - upgrade_runner_endpoint = os.environ.get("UPGRADE_RUNNER_ENDPOINT", "UNDEFINED") + upgrade_runner_endpoint = ( + upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT + ) if upgrade_runner_endpoint == "UNDEFINED": - raise Exception("The UPGRADE_RUNNER_ENDPOINT environment variable is undefined!") + raise Exception( + "The UPGRADE_RUNNER_ENDPOINT environment variable is undefined!" + ) - response = requests.post(f"{upgrade_runner_endpoint}/run_firmware_upgrade", json=rsu_upgrade_info) + response = requests.post( + f"{upgrade_runner_endpoint}/run_firmware_upgrade", json=rsu_upgrade_info + ) # Remove redundant ipv4_address from rsu since it is the key for active_upgrades del rsu_upgrade_info["ipv4_address"] # If the POST response includes a 201 code, add it to the active upgrades if response.status_code == 201: - logging.info(f"Firmware upgrade runner successfully requested to begin the upgrade for {rsu_to_upgrade}") + logging.info( + f"Firmware upgrade runner successfully requested to begin the upgrade for {rsu_to_upgrade}" + ) active_upgrades[rsu_to_upgrade] = rsu_upgrade_info else: logging.error( @@ -345,7 +351,7 @@ def reset_consecutive_failure_count_for_rsu(rsu_ip): def is_rsu_at_max_retries_limit(rsu_ip): - max_retries = int(os.environ.get("FW_UPGRADE_MAX_RETRY_LIMIT", "3")) + max_retries = upgrade_scheduler_environment.FW_UPGRADE_MAX_RETRY_LIMIT query_result = pgquery.query_db( f"select consecutive_failures from consecutive_firmware_upgrade_failures where rsu_id=(select rsu_id from rsus where ipv4_address='{rsu_ip}')" ) @@ -372,7 +378,9 @@ def serve_rest_api(): def init_background_task(): - logging.info("Initiating the Firmware Manager Upgrade Scheduler background checker...") + logging.info( + "Initiating the Firmware Manager Upgrade Scheduler background checker..." + ) # Run scheduler for async RSU firmware upgrade checks scheduler = BackgroundScheduler({"apscheduler.timezone": "UTC"}) scheduler.add_job(check_for_upgrades, "cron", minute="0") diff --git a/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler_environment.py b/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler_environment.py new file mode 100644 index 000000000..53e6c1c2b --- /dev/null +++ b/services/addons/images/firmware_manager/upgrade_scheduler/upgrade_scheduler_environment.py @@ -0,0 +1,5 @@ +from common.common_environment import get_env_var + +UPGRADE_RUNNER_ENDPOINT = get_env_var("UPGRADE_RUNNER_ENDPOINT", "UNDEFINED") +ACTIVE_UPGRADE_LIMIT = int(get_env_var("ACTIVE_UPGRADE_LIMIT", "1")) +FW_UPGRADE_MAX_RETRY_LIMIT = int(get_env_var("FW_UPGRADE_MAX_RETRY_LIMIT", "3")) diff --git a/services/addons/images/iss_health_check/iss_health_check_environment.py b/services/addons/images/iss_health_check/iss_health_check_environment.py new file mode 100644 index 000000000..18ae20ef6 --- /dev/null +++ b/services/addons/images/iss_health_check/iss_health_check_environment.py @@ -0,0 +1,21 @@ +from common.common_environment import get_env_var + + +def process_storage_type(val: str, default: str) -> str: + if not val: + return default + value = val.lower() + if value not in ["postgres", "gcp"]: + raise ValueError("Invalid STORAGE_TYPE. Must be 'postgres' or 'gcp'.") + return value + + +PROJECT_ID=get_env_var("PROJECT_ID", error=True) +ISS_API_KEY=get_env_var("ISS_API_KEY", error=True) +ISS_API_KEY_NAME=get_env_var("ISS_API_KEY_NAME", error=True) +ISS_KEY_TABLE_NAME=get_env_var("ISS_KEY_TABLE_NAME", error=True) +ISS_SCMS_TOKEN_REST_ENDPOINT=get_env_var("ISS_SCMS_TOKEN_REST_ENDPOINT", error=True) +ISS_SCMS_VEHICLE_REST_ENDPOINT=get_env_var("ISS_SCMS_VEHICLE_REST_ENDPOINT", error=True) +ISS_PROJECT_ID=get_env_var("ISS_PROJECT_ID", error=True) + +STORAGE_TYPE = process_storage_type(get_env_var("STORAGE_TYPE", warn=False), "postgres") diff --git a/services/addons/images/iss_health_check/iss_health_checker.py b/services/addons/images/iss_health_check/iss_health_checker.py index 5fa68338f..a5193701d 100644 --- a/services/addons/images/iss_health_check/iss_health_checker.py +++ b/services/addons/images/iss_health_check/iss_health_checker.py @@ -1,11 +1,11 @@ from datetime import datetime import requests import logging -import os import iss_token import common.pgquery as pgquery from dataclasses import dataclass, field from typing import Dict +import iss_health_check_environment # Set up logging @@ -68,8 +68,8 @@ def get_scms_status_data(): iss_headers["x-api-key"] = iss_token.get_token() # Create the GET request string - iss_base = os.environ["ISS_SCMS_VEHICLE_REST_ENDPOINT"] - project_id = os.environ["ISS_PROJECT_ID"] + iss_base = iss_health_check_environment.ISS_SCMS_VEHICLE_REST_ENDPOINT + project_id = iss_health_check_environment.ISS_PROJECT_ID page_size = 200 page = 0 messages_processed = 0 @@ -191,11 +191,5 @@ def validate_scms_data(value): if __name__ == "__main__": - # Configure logging based on ENV var or use default if not set - log_level = ( - "INFO" if "LOGGING_LEVEL" not in os.environ else os.environ["LOGGING_LEVEL"] - ) - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - scms_statuses = get_scms_status_data() insert_scms_data(scms_statuses) diff --git a/services/addons/images/iss_health_check/iss_token.py b/services/addons/images/iss_health_check/iss_token.py index 77b761d42..18bc89bcd 100644 --- a/services/addons/images/iss_health_check/iss_token.py +++ b/services/addons/images/iss_health_check/iss_token.py @@ -1,34 +1,15 @@ from google.cloud import secretmanager import common.pgquery as pgquery import requests -import os import json import uuid import logging +import iss_health_check_environment # Set up logging logger = logging.getLogger(__name__) -# Get storage type from environment variable -def get_storage_type(): - """Get the storage type for the ISS SCMS API token - """ - try : - os.environ["STORAGE_TYPE"] - except KeyError: - logger.error("STORAGE_TYPE environment variable not set, exiting") - exit(1) - - storageTypeCaseInsensitive = os.environ["STORAGE_TYPE"].casefold() - if storageTypeCaseInsensitive == "gcp": - return "gcp" - elif storageTypeCaseInsensitive == "postgres": - return "postgres" - else: - logger.error("STORAGE_TYPE environment variable not set to a valid value, exiting") - exit(1) - # GCP Secret Manager functions def create_secret(client, secret_id, parent): @@ -137,11 +118,10 @@ def add_data(table_name, common_name, token): # Main function def get_token(): - storage_type = get_storage_type() - if storage_type == "gcp": + if iss_health_check_environment.STORAGE_TYPE == "gcp": client = secretmanager.SecretManagerServiceClient() secret_id = "iss-token-secret" - parent = f"projects/{os.environ['PROJECT_ID']}" + parent = f"projects/{iss_health_check_environment.PROJECT_ID}" # Check to see if the GCP secret exists data_exists = check_if_secret_exists(client, secret_id, parent) @@ -156,11 +136,11 @@ def get_token(): # If there is no available ISS token secret, create secret logger.debug("Secret does not exist, creating secret") create_secret(client, secret_id, parent) - # Use environment variable for first run with new secret - token = os.environ["ISS_API_KEY"] - elif storage_type == "postgres": - key_table_name = os.environ["ISS_KEY_TABLE_NAME"] - + # Use iss_health_check_environment variable for first run with new secret + token = iss_health_check_environment.ISS_API_KEY + elif iss_health_check_environment.STORAGE_TYPE == "postgres": + key_table_name = iss_health_check_environment.ISS_KEY_TABLE_NAME + # check to see if data exists in the table data_exists = check_if_data_exists(key_table_name) @@ -172,17 +152,19 @@ def get_token(): token = value["token"] logger.debug(f"Received token: {friendly_name} with id {id}") else: - # if there is no data, use environment variable for first run - token = os.environ["ISS_API_KEY"] + # if there is no data, use iss_health_check_environment variable for first run + token = iss_health_check_environment.ISS_API_KEY # Pull new ISS SCMS API token - iss_base = os.environ["ISS_SCMS_TOKEN_REST_ENDPOINT"] + iss_base = iss_health_check_environment.ISS_SCMS_TOKEN_REST_ENDPOINT # Create HTTP request headers iss_headers = {"x-api-key": token} # Create the POST body - new_friendly_name = f"{os.environ['ISS_API_KEY_NAME']}_{str(uuid.uuid4())}" + new_friendly_name = ( + f"{iss_health_check_environment.ISS_API_KEY_NAME}_{str(uuid.uuid4())}" + ) iss_post_body = {"friendlyName": new_friendly_name, "expireDays": 1} # Create new ISS SCMS API Token to ensure its freshness @@ -203,10 +185,10 @@ def get_token(): version_data = {"name": new_friendly_name, "token": new_token} - if get_storage_type() == "gcp": + if iss_health_check_environment.STORAGE_TYPE == "gcp": # Add new version to the secret add_secret_version(client, secret_id, parent, version_data) - elif get_storage_type() == "postgres": + elif iss_health_check_environment.STORAGE_TYPE == "postgres": # add new entry to the table add_data(key_table_name, new_friendly_name, new_token) diff --git a/services/addons/images/obu_ota_server/commsignia_manifest.py b/services/addons/images/obu_ota_server/commsignia_manifest.py index e6d368d27..96f71b9d3 100644 --- a/services/addons/images/obu_ota_server/commsignia_manifest.py +++ b/services/addons/images/obu_ota_server/commsignia_manifest.py @@ -2,7 +2,6 @@ import datetime import re from typing import List -import os import copy document = { diff --git a/services/addons/images/obu_ota_server/obu_ota_server.py b/services/addons/images/obu_ota_server/obu_ota_server.py index 06386b8fd..8b29405b4 100644 --- a/services/addons/images/obu_ota_server/obu_ota_server.py +++ b/services/addons/images/obu_ota_server/obu_ota_server.py @@ -2,26 +2,24 @@ from fastapi import FastAPI, Request, Response, HTTPException, Depends from fastapi.security import HTTPBasic, HTTPBasicCredentials from common import gcs_utils, pgquery -import commsignia_manifest -import os +from addons.images.obu_ota_server import commsignia_manifest import glob +import os import aiofiles -from starlette.responses import Response import logging from datetime import datetime import asyncio +import obu_ota_server_environment app = FastAPI() -log_level = "INFO" if "LOGGING_LEVEL" not in os.environ else os.environ["LOGGING_LEVEL"] -logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) security = HTTPBasic() commsignia_file_ext = ".tar.sig" def authenticate_user(credentials: HTTPBasicCredentials = Depends(security)) -> str: - correct_username = os.getenv("OTA_USERNAME") - correct_password = os.getenv("OTA_PASSWORD") + correct_username = obu_ota_server_environment.OTA_USERNAME + correct_password = obu_ota_server_environment.OTA_PASSWORD if ( credentials.username != correct_username or credentials.password != correct_password @@ -43,20 +41,20 @@ async def read_root(request: Request): def get_firmware_list() -> list: - blob_storage_provider = os.getenv("BLOB_STORAGE_PROVIDER", "DOCKER") + blob_storage_provider = obu_ota_server_environment.BLOB_STORAGE_PROVIDER files = [] file_extension = commsignia_file_ext if blob_storage_provider.upper() == "DOCKER": files = glob.glob(f"/firmwares/*{file_extension}") elif blob_storage_provider.upper() == "GCP": - blob_storage_path = os.getenv("BLOB_STORAGE_PATH", "DOCKER") + blob_storage_path = obu_ota_server_environment.BLOB_STORAGE_PATH files = gcs_utils.list_gcs_blobs(blob_storage_path, file_extension) return files def get_host_name() -> str: - host_name = os.getenv("SERVER_HOST", "localhost") - tls_enabled = os.getenv("NGINX_ENCRYPTION", "plain") + host_name = obu_ota_server_environment.SERVER_HOST + tls_enabled = obu_ota_server_environment.NGINX_ENCRYPTION if tls_enabled.lower() == "ssl": host_name = "https://" + host_name else: @@ -78,7 +76,7 @@ async def get_manifest(request: Request) -> dict[str, Any]: def get_firmware(firmware_id: str, local_file_path: str) -> bool: try: - blob_storage_provider = os.getenv("BLOB_STORAGE_PROVIDER", "DOCKER") + blob_storage_provider = obu_ota_server_environment.BLOB_STORAGE_PROVIDER # checks if firmware exists locally if not os.path.exists(local_file_path): # If configured to only use local storage, return False as firmware does not exist @@ -131,12 +129,11 @@ async def read_file( def removed_old_logs(serialnum: str): try: - max_count = int(os.getenv("MAX_COUNT", 10)) success_count = pgquery.query_db( f"SELECT COUNT(*) FROM public.obu_ota_requests WHERE obu_sn = '{serialnum}' AND error_status = B'0'" ) - if success_count[0][0] > max_count: - excess_count = success_count[0][0] - max_count + if success_count[0][0] > obu_ota_server_environment.MAX_COUNT: + excess_count = success_count[0][0] - obu_ota_server_environment.MAX_COUNT oldest_entries = pgquery.query_db( f"SELECT request_id FROM public.obu_ota_requests WHERE obu_sn = '{serialnum}' AND error_status = B'0' ORDER BY request_datetime ASC LIMIT {excess_count}" ) @@ -200,7 +197,7 @@ async def get_fw(request: Request, firmware_id: str): content=data, media_type="application/octet-stream", headers=headers ) except Exception as e: - asyncio.create_task(log_request(1, request, firmware_id, 1, e.detail)) + asyncio.create_task(log_request(1, request, firmware_id, 1, str(e))) logging.error( f"get_fw: Error responding with firmware with error: {e} for firmware_id: {firmware_id}" ) diff --git a/services/addons/images/obu_ota_server/obu_ota_server_environment.py b/services/addons/images/obu_ota_server/obu_ota_server_environment.py new file mode 100644 index 000000000..c79a3ec61 --- /dev/null +++ b/services/addons/images/obu_ota_server/obu_ota_server_environment.py @@ -0,0 +1,9 @@ +from common.common_environment import get_env_var + +OTA_USERNAME=get_env_var("OTA_USERNAME", "admin") +OTA_PASSWORD=get_env_var("OTA_PASSWORD", error=True) +BLOB_STORAGE_PROVIDER=get_env_var("BLOB_STORAGE_PROVIDER", "DOCKER") +BLOB_STORAGE_PATH=get_env_var("BLOB_STORAGE_PATH", "DOCKER") +SERVER_HOST=get_env_var("SERVER_HOST", "localhost") +NGINX_ENCRYPTION=get_env_var("NGINX_ENCRYPTION", "plain", warn=False) +MAX_COUNT=int(get_env_var("MAX_COUNT", "10", warn=False)) diff --git a/services/addons/images/rsu_status_check/purger.py b/services/addons/images/rsu_status_check/purger.py index 7b1ec0a35..fbfc8a8d3 100644 --- a/services/addons/images/rsu_status_check/purger.py +++ b/services/addons/images/rsu_status_check/purger.py @@ -1,7 +1,8 @@ -from datetime import datetime, timedelta, timezone -import os +from datetime import datetime, timedelta import logging import common.pgquery as pgquery +import rsu_status_check_environment +from common import common_environment def get_all_rsus(): @@ -83,17 +84,12 @@ def purge_ping_data(stale_period): if __name__ == "__main__": - # Configure logging based on ENV var or use default if not set - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - run_service = ( - os.environ.get("RSU_PING", "False").lower() == "true" - or os.environ.get("ZABBIX", "False").lower() == "true" + rsu_status_check_environment.RSU_PING or rsu_status_check_environment.ZABBIX ) if not run_service: logging.info("The purger service is disabled and will not run") exit() - stale_period = int(os.environ["STALE_PERIOD"]) + stale_period = rsu_status_check_environment.STALE_PERIOD_HOURS purge_ping_data(stale_period) diff --git a/services/addons/images/rsu_status_check/rsu_msgfwd_fetch.py b/services/addons/images/rsu_status_check/rsu_msgfwd_fetch.py index 7b7487974..66017eab0 100644 --- a/services/addons/images/rsu_status_check/rsu_msgfwd_fetch.py +++ b/services/addons/images/rsu_status_check/rsu_msgfwd_fetch.py @@ -1,20 +1,14 @@ -import os import logging from common.snmp.update_pg.update_rsu_message_forward import ( UpdatePostgresRsuMessageForward, ) +import rsu_status_check_environment # Pulls the latest message forwarding configuration information from all RSUs in the PostgreSQL database # through SNMP and updates the PostgreSQL database with the latest information def main(): - # Configure logging based on ENV var or use default if not set - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - log_level = "INFO" if log_level == "" else log_level - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - - run_service = os.environ.get("RSU_MSGFWD_FETCH", "False").lower() == "true" - if not run_service: + if not rsu_status_check_environment.RSU_MSGFWD_FETCH: logging.info("The rsu-msgfwd-fetch service is disabled and will not run") return diff --git a/services/addons/images/rsu_status_check/rsu_ping_fetch.py b/services/addons/images/rsu_status_check/rsu_ping_fetch.py index 5393e25fd..d6b718855 100644 --- a/services/addons/images/rsu_status_check/rsu_ping_fetch.py +++ b/services/addons/images/rsu_status_check/rsu_ping_fetch.py @@ -1,7 +1,7 @@ import requests -import os import logging import common.pgquery as pgquery +import rsu_status_check_environment def get_rsu_data(): @@ -37,7 +37,7 @@ def insert_rsu_ping(request_json): class RsuStatusFetch: def __init__(self): - self.ZABBIX_ENDPOINT = os.environ["ZABBIX_ENDPOINT"] + self.ZABBIX_ENDPOINT = rsu_status_check_environment.ZABBIX_ENDPOINT self.ZABBIX_AUTH = "" def setZabbixAuth(self): @@ -47,8 +47,8 @@ def setZabbixAuth(self): "method": "user.login", "id": 1, "params": { - "username": os.environ["ZABBIX_USER"], - "password": os.environ["ZABBIX_PASSWORD"], + "username": rsu_status_check_environment.ZABBIX_USER, + "password": rsu_status_check_environment.ZABBIX_PASSWORD, }, } @@ -144,14 +144,8 @@ def run(self): if __name__ == "__main__": - # Configure logging based on ENV var or use default if not set - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - log_level = "INFO" if log_level == "" else log_level - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - run_service = ( - os.environ.get("RSU_PING", "False").lower() == "true" - and os.environ.get("ZABBIX", "False").lower() == "true" + rsu_status_check_environment.RSU_PING and rsu_status_check_environment.ZABBIX ) if not run_service: logging.info("The rsu-ping-fetch service is disabled and will not run") diff --git a/services/addons/images/rsu_status_check/rsu_pinger.py b/services/addons/images/rsu_status_check/rsu_pinger.py index db403a30b..0084312b0 100644 --- a/services/addons/images/rsu_status_check/rsu_pinger.py +++ b/services/addons/images/rsu_status_check/rsu_pinger.py @@ -1,9 +1,9 @@ -import os import logging import time import common.pgquery as pgquery from datetime import datetime from subprocess import Popen, DEVNULL +import rsu_status_check_environment def insert_ping_data(ping_data, ping_time): @@ -83,14 +83,8 @@ def run_rsu_pinger(): if __name__ == "__main__": - # Configure logging based on ENV var or use default if not set - log_level = os.environ.get("LOGGING_LEVEL", "INFO") - log_level = "INFO" if log_level == "" else log_level - logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) - run_service = ( - os.environ.get("RSU_PING", "False").lower() == "true" - and os.environ.get("ZABBIX", "False").lower() == "false" + rsu_status_check_environment.RSU_PING and rsu_status_check_environment.ZABBIX ) if not run_service: logging.info("The rsu-pinger service is disabled and will not run") diff --git a/services/addons/images/rsu_status_check/rsu_status_check_environment.py b/services/addons/images/rsu_status_check/rsu_status_check_environment.py new file mode 100644 index 000000000..455eb4b09 --- /dev/null +++ b/services/addons/images/rsu_status_check/rsu_status_check_environment.py @@ -0,0 +1,10 @@ +from common.common_environment import get_env_var + +RSU_PING=get_env_var("RSU_PING", "False", warn=False).lower() == "true" +ZABBIX=get_env_var("ZABBIX", "False", warn=False).lower() == "true" +STALE_PERIOD_HOURS=int(get_env_var("STALE_PERIOD", "24", warn=False)) +RSU_MSGFWD_FETCH=get_env_var("RSU_MSGFWD_FETCH", "False", warn=False).lower() == "true" + +ZABBIX_ENDPOINT=get_env_var("ZABBIX_ENDPOINT", error=True) +ZABBIX_USER=get_env_var("ZABBIX_USER", error=True) +ZABBIX_PASSWORD=get_env_var("ZABBIX_PASSWORD", error=True) diff --git a/services/addons/tests/count_metric/conftest.py b/services/addons/tests/count_metric/conftest.py new file mode 100644 index 000000000..a884e1513 --- /dev/null +++ b/services/addons/tests/count_metric/conftest.py @@ -0,0 +1,7 @@ +import os + +os.environ["IAPI_ENDPOINT"] = "localhost:8089" +os.environ["KC_ENDPOINT"] = "http://localhost:8084" +os.environ["KC_REALM"] = "cvmanager" +os.environ["KC_SA_CLIENT_ID"] = "sa_count_metric" +os.environ["KC_SA_CLIENT_SECRET"] = "sa_count_metric_secret" diff --git a/services/addons/tests/count_metric/test_daily_emailer.py b/services/addons/tests/count_metric/test_daily_emailer.py index 84b57c7fd..f6a41746d 100644 --- a/services/addons/tests/count_metric/test_daily_emailer.py +++ b/services/addons/tests/count_metric/test_daily_emailer.py @@ -1,4 +1,3 @@ -import os from datetime import datetime, timedelta from mock import MagicMock, patch from addons.images.count_metric import daily_emailer @@ -126,7 +125,7 @@ def test_query_mongo_out_counts(): }, { "$group": { - "_id": f"$metadata.originIp", + "_id": "$metadata.originIp", "count": {"$sum": 1}, } }, @@ -198,45 +197,43 @@ def test_prepare_org_rsu_dict(mock_query_db): daily_emailer.message_types = ["BSM", "TIM", "Map", "SPaT", "SRM", "SSM"] -@patch.dict( - os.environ, - { - "DEPLOYMENT_TITLE": "Test", - "SMTP_SERVER_IP": "10.0.0.1", - "SMTP_USERNAME": "username", - "SMTP_PASSWORD": "password", - "SMTP_EMAIL": "test@gmail.com", - }, -) -@patch("addons.images.count_metric.daily_emailer.EmailSender") -@patch("addons.images.count_metric.daily_emailer.get_email_list") -def test_email_daily_counts(mock_email_list, mock_emailsender): - mock_email_list.return_value = ["bob@gmail.com"] - emailsender_obj = mock_emailsender.return_value - - daily_emailer.email_daily_counts("Test Org", "test") - - emailsender_obj.send.assert_called_once_with( - sender="test@gmail.com", - recipient="bob@gmail.com", - subject="Test Org Test Counts", - message="test", - replyEmail="", - username="username", - password="password", - pretty=True, +@patch("count_metric_environment.IAPI_ENDPOINT", "http://test.test") +@patch("count_metric_environment.KC_SA_CLIENT_ID", "sa_client_id") +@patch("count_metric_environment.KC_SA_CLIENT_SECRET", "sa_client_secret") +@patch("addons.images.count_metric.daily_emailer.EmailApi") +@patch("addons.images.count_metric.daily_emailer.KeycloakServiceAccountApi") +def test_email_daily_counts(mock_kc_api, mock_email_api): + email_api_obj = mock_email_api.return_value + + org_name = "Test Org" + deployment_title = "Test Deployment" + start_date = datetime(2023, 1, 1, 0, 0, 0) + end_date = datetime(2023, 1, 2, 0, 0, 0) + message_type_list = ["BSM", "TIM"] + counts = [ + { + "rsu_ip": "10.0.0.1", + "counts": {"BSM": {"in": 10, "out": 5}}, + "primary_route": "Route 1", + } + ] + + daily_emailer.email_daily_counts( + org_name, deployment_title, start_date, end_date, message_type_list, counts + ) + + mock_email_api.assert_called_once_with( + iapi_base_url="http://test.test", kc_api=mock_kc_api() + ) + email_api_obj.send_message_counts.assert_called_once_with( + org_name, deployment_title, start_date, end_date, message_type_list, counts ) -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "mongo-uri", - "MONGO_DB_NAME": "test_db", - }, -) +@patch("count_metric_environment.DEPLOYMENT_TITLE", "Test Deployment") +@patch("count_metric_environment.MONGO_DB_URI", "mongo-uri") +@patch("count_metric_environment.MONGO_DB_NAME", "test_db") @patch("addons.images.count_metric.daily_emailer.MongoClient", MagicMock()) -@patch("addons.images.count_metric.daily_emailer.gen_email.generate_email_body") @patch("addons.images.count_metric.daily_emailer.email_daily_counts") @patch("addons.images.count_metric.daily_emailer.query_mongo_out_counts") @patch("addons.images.count_metric.daily_emailer.query_mongo_in_counts") @@ -246,13 +243,19 @@ def test_run_daily_emailer( mock_query_mongo_in_counts, mock_query_mongo_out_counts, mock_email_daily_counts, - mock_gen_email, ): - mock_prepare_org_rsu_dict.return_value = {"Test Org": {}} + mock_prepare_org_rsu_dict.return_value = { + "Test Org": { + "10.0.0.1": { + "primary_route": "Route 1", + "counts": {"BSM": {"in": 10, "out": 5}}, + } + } + } + daily_emailer.run_daily_emailer() mock_prepare_org_rsu_dict.assert_called_once() mock_query_mongo_in_counts.assert_called_once() mock_query_mongo_out_counts.assert_called_once() mock_email_daily_counts.assert_called_once() - mock_gen_email.assert_called_once() diff --git a/services/addons/tests/count_metric/test_gen_email.py b/services/addons/tests/count_metric/test_gen_email.py index 98196d607..9fb2f4c9f 100644 --- a/services/addons/tests/count_metric/test_gen_email.py +++ b/services/addons/tests/count_metric/test_gen_email.py @@ -113,10 +113,7 @@ def test_generate_count_table_empty(): assert result == "" -@patch.dict( - os.environ, - {"DEPLOYMENT_TITLE": "Test"}, -) +@patch("count_metric_environment.DEPLOYMENT_TITLE", "Test") @patch("addons.images.count_metric.gen_email.generate_count_table") def test_generate_email_body(mock_generate_count_table): mock_generate_count_table.return_value = "" diff --git a/services/addons/tests/count_metric/test_mongo_counter.py b/services/addons/tests/count_metric/test_mongo_counter.py index 3cab00fc9..e34a49628 100644 --- a/services/addons/tests/count_metric/test_mongo_counter.py +++ b/services/addons/tests/count_metric/test_mongo_counter.py @@ -1,5 +1,4 @@ -import os -from datetime import datetime, timedelta +from datetime import datetime from mock import MagicMock, patch from addons.images.count_metric import mongo_counter @@ -22,7 +21,6 @@ def test_write_counts_empty(): mock_collection.insert_many.assert_not_called() -@patch.dict(os.environ, {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "name"}) def test_count_query_bsm(): mock_collection = MagicMock() mock_collection.aggregate.return_value = [ @@ -53,7 +51,6 @@ def test_count_query_bsm(): assert result == expected_result -@patch.dict(os.environ, {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "name"}) @patch("addons.images.count_metric.mongo_counter.write_counts") @patch("addons.images.count_metric.mongo_counter.count_query") def test_run_mongo_counter(mock_count_query, mock_write_counts): diff --git a/services/addons/tests/firmware_manager/upgrade_runner/conftest.py b/services/addons/tests/firmware_manager/upgrade_runner/conftest.py new file mode 100644 index 000000000..49e44b6ae --- /dev/null +++ b/services/addons/tests/firmware_manager/upgrade_runner/conftest.py @@ -0,0 +1,7 @@ +import os + +os.environ["IAPI_ENDPOINT"] = "localhost:8089" +os.environ["KC_ENDPOINT"] = "http://localhost:8084" +os.environ["KC_REALM"] = "cvmanager" +os.environ["KC_SA_CLIENT_ID"] = "sa_firmware_upgrade_runner" +os.environ["KC_SA_CLIENT_SECRET"] = "sa_firmware_upgrade_runner_secret" diff --git a/services/addons/tests/firmware_manager/upgrade_runner/test_upgrader.py b/services/addons/tests/firmware_manager/upgrade_runner/test_upgrader.py index 3589e4f21..2391c42f8 100644 --- a/services/addons/tests/firmware_manager/upgrade_runner/test_upgrader.py +++ b/services/addons/tests/firmware_manager/upgrade_runner/test_upgrader.py @@ -74,7 +74,10 @@ def test_cleanup_not_exist(mock_Path, mock_shutil): mock_shutil.rmtree.assert_not_called() -@patch.dict(os.environ, {"BLOB_STORAGE_PROVIDER": "GCP"}) +@patch( + "upgrade_runner_environment.BLOB_STORAGE_PROVIDER", + "GCP", +) @patch("common.gcs_utils.download_gcp_blob") @patch("addons.images.firmware_manager.upgrade_runner.upgrader.Path") def test_download_blob_gcp(mock_Path, mock_download_gcp_blob): @@ -91,7 +94,10 @@ def test_download_blob_gcp(mock_Path, mock_download_gcp_blob): ) -@patch.dict(os.environ, {"BLOB_STORAGE_PROVIDER": "DOCKER"}) +@patch( + "upgrade_runner_environment.BLOB_STORAGE_PROVIDER", + "DOCKER", +) @patch( "addons.images.firmware_manager.upgrade_runner.upgrader.download_blob.download_docker_blob" ) @@ -109,7 +115,10 @@ def test_download_blob_docker(mock_Path, mock_download_docker_blob): ) -@patch.dict(os.environ, {"BLOB_STORAGE_PROVIDER": "Test"}) +@patch( + "upgrade_runner_environment.BLOB_STORAGE_PROVIDER", + "Test", +) @patch("addons.images.firmware_manager.upgrade_runner.upgrader.logging") @patch("common.gcs_utils.download_gcp_blob") @patch("addons.images.firmware_manager.upgrade_runner.upgrader.Path") @@ -125,7 +134,10 @@ def test_download_blob_not_supported(mock_Path, mock_download_gcp_blob, mock_log mock_logging.error.assert_called_with("Unsupported blob storage provider") -@patch.dict("os.environ", {"UPGRADE_SCHEDULER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_runner_environment.UPGRADE_SCHEDULER_ENDPOINT", + "http://test-endpoint", +) @patch("addons.images.firmware_manager.upgrade_runner.upgrader.logging") @patch("addons.images.firmware_manager.upgrade_runner.upgrader.requests") def test_notify_firmware_manager_success(mock_requests, mock_logging): @@ -142,7 +154,10 @@ def test_notify_firmware_manager_success(mock_requests, mock_logging): mock_requests.post.assert_called_with(expected_url, json=expected_body) -@patch.dict("os.environ", {"UPGRADE_SCHEDULER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_runner_environment.UPGRADE_SCHEDULER_ENDPOINT", + "http://test-endpoint", +) @patch("addons.images.firmware_manager.upgrade_runner.upgrader.logging") @patch("addons.images.firmware_manager.upgrade_runner.upgrader.requests") def test_notify_firmware_manager_fail(mock_requests, mock_logging): diff --git a/services/addons/tests/firmware_manager/upgrade_runner/test_yunex_upgrader.py b/services/addons/tests/firmware_manager/upgrade_runner/test_yunex_upgrader.py index 309d81440..cb39fa58a 100644 --- a/services/addons/tests/firmware_manager/upgrade_runner/test_yunex_upgrader.py +++ b/services/addons/tests/firmware_manager/upgrade_runner/test_yunex_upgrader.py @@ -178,6 +178,7 @@ def test_yunex_upgrader_core_upgrade_fail( test_yunex_upgrader.run_xfer_upgrade = MagicMock(return_value=-1) test_yunex_upgrader.wait_until_online = MagicMock(return_value=0) test_yunex_upgrader.cleanup = MagicMock() + test_yunex_upgrader.send_error_email = MagicMock() notify = MagicMock() test_yunex_upgrader.notify_firmware_manager = notify @@ -231,6 +232,7 @@ def test_yunex_upgrader_core_ping_fail( test_yunex_upgrader.run_xfer_upgrade = MagicMock(return_value=0) test_yunex_upgrader.wait_until_online = MagicMock(return_value=-1) test_yunex_upgrader.cleanup = MagicMock() + test_yunex_upgrader.send_error_email = MagicMock() notify = MagicMock() test_yunex_upgrader.notify_firmware_manager = notify @@ -284,6 +286,7 @@ def test_yunex_upgrader_sdk_upgrade_fail( test_yunex_upgrader.run_xfer_upgrade = MagicMock(side_effect=[0, -1]) test_yunex_upgrader.wait_until_online = MagicMock(return_value=0) test_yunex_upgrader.cleanup = MagicMock() + test_yunex_upgrader.send_error_email = MagicMock() notify = MagicMock() test_yunex_upgrader.notify_firmware_manager = notify @@ -338,6 +341,7 @@ def test_yunex_upgrader_sdk_ping_fail( test_yunex_upgrader.run_xfer_upgrade = MagicMock(return_value=0) test_yunex_upgrader.wait_until_online = MagicMock(side_effect=[0, -1]) test_yunex_upgrader.cleanup = MagicMock() + test_yunex_upgrader.send_error_email = MagicMock() notify = MagicMock() test_yunex_upgrader.notify_firmware_manager = notify @@ -392,6 +396,7 @@ def test_yunex_upgrader_provision_upgrade_fail( test_yunex_upgrader.run_xfer_upgrade = MagicMock(side_effect=[0, 0, -1]) test_yunex_upgrader.wait_until_online = MagicMock(return_value=0) test_yunex_upgrader.cleanup = MagicMock() + test_yunex_upgrader.send_error_email = MagicMock() notify = MagicMock() test_yunex_upgrader.notify_firmware_manager = notify diff --git a/services/addons/tests/firmware_manager/upgrade_scheduler/test_upgrade_scheduler.py b/services/addons/tests/firmware_manager/upgrade_scheduler/test_upgrade_scheduler.py index 4a10f58f2..878561673 100644 --- a/services/addons/tests/firmware_manager/upgrade_scheduler/test_upgrade_scheduler.py +++ b/services/addons/tests/firmware_manager/upgrade_scheduler/test_upgrade_scheduler.py @@ -42,7 +42,10 @@ def test_get_rsu_upgrade_data_one(mock_querydb): # start_tasks_from_queue tests -@patch.dict("os.environ", {"UPGRADE_RUNNER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT", + "http://test-endpoint", +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.active_upgrades", {}, @@ -133,7 +136,14 @@ def test_start_tasks_from_queue_no_env_var(mock_post, mock_logging): ) -@patch.dict("os.environ", {"UPGRADE_RUNNER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT", + "http://test-endpoint", +) +@patch( + "upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT", + "http://test-endpoint", +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.active_upgrades", {}, @@ -188,7 +198,10 @@ def test_start_tasks_from_queue_post_success(mock_post, mock_logging): mock_logging.error.assert_not_called() -@patch.dict("os.environ", {"UPGRADE_RUNNER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT", + "http://test-endpoint", +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.active_upgrades", {}, @@ -845,7 +858,10 @@ def test_list_active_upgrades(mock_logging): # check_for_upgrades tests -@patch.dict("os.environ", {"UPGRADE_RUNNER_ENDPOINT": "http://test-endpoint"}) +@patch( + "upgrade_scheduler_environment.UPGRADE_RUNNER_ENDPOINT", + "http://test-endpoint", +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.was_latest_ping_successful_for_rsu" ) @@ -1007,7 +1023,10 @@ def test_reset_consecutive_failure_count_for_rsu(mock_write_db): mock_write_db.assert_called_with(expected_query) -@patch.dict("os.environ", {"FW_UPGRADE_MAX_RETRY_LIMIT": "3"}) +@patch( + "upgrade_scheduler_environment.FW_UPGRADE_MAX_RETRY_LIMIT", + 3, +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.pgquery.query_db" ) @@ -1021,11 +1040,14 @@ def test_is_rsu_at_max_retries_limit_TRUE(mock_query_db): result = upgrade_scheduler.is_rsu_at_max_retries_limit(rsu_ip) # verify - assert result == True + assert result is True mock_query_db.assert_called_with(expected_query) -@patch.dict("os.environ", {"FW_UPGRADE_MAX_RETRY_LIMIT": "3"}) +@patch( + "upgrade_scheduler_environment.FW_UPGRADE_MAX_RETRY_LIMIT", + 3, +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.pgquery.query_db" ) @@ -1039,11 +1061,14 @@ def test_is_rsu_at_max_retries_limit_FALSE(mock_query_db): result = upgrade_scheduler.is_rsu_at_max_retries_limit(rsu_ip) # verify - assert result == False + assert result is False mock_query_db.assert_called_with(expected_query) -@patch.dict("os.environ", {"FW_UPGRADE_MAX_RETRY_LIMIT": "3"}) +@patch( + "upgrade_scheduler_environment.FW_UPGRADE_MAX_RETRY_LIMIT", + 3, +) @patch( "addons.images.firmware_manager.upgrade_scheduler.upgrade_scheduler.pgquery.query_db" ) @@ -1057,7 +1082,7 @@ def test_is_rsu_at_max_retries_limit_NO_RESULTS(mock_query_db): result = upgrade_scheduler.is_rsu_at_max_retries_limit(rsu_ip) # verify - assert result == False + assert result is False mock_query_db.assert_called_with(expected_query) @@ -1099,21 +1124,10 @@ def test_init_background_task(mock_bgscheduler): mock_bgscheduler_obj.start.assert_called_with() -def test_get_upgrade_limit_no_env(): - limit = upgrade_scheduler.get_upgrade_limit() - assert limit == 1 - - -@patch.dict("os.environ", {"ACTIVE_UPGRADE_LIMIT": "5"}) +@patch( + "upgrade_scheduler_environment.ACTIVE_UPGRADE_LIMIT", + 5, +) def test_get_upgrade_limit_with_env(): limit = upgrade_scheduler.get_upgrade_limit() assert limit == 5 - - -@patch.dict("os.environ", {"ACTIVE_UPGRADE_LIMIT": "bad_value"}) -def test_get_upgrade_limit_with_bad_env(): - with pytest.raises( - ValueError, - match="The environment variable 'ACTIVE_UPGRADE_LIMIT' must be an integer.", - ): - upgrade_scheduler.get_upgrade_limit() diff --git a/services/addons/tests/iss_health_check/conftest.py b/services/addons/tests/iss_health_check/conftest.py new file mode 100644 index 000000000..f03fbd385 --- /dev/null +++ b/services/addons/tests/iss_health_check/conftest.py @@ -0,0 +1,9 @@ +import os + +os.environ['PROJECT_ID'] = 'project-id' +os.environ['ISS_API_KEY'] = 'iss-api-key' +os.environ['ISS_API_KEY_NAME'] = 'iss-api-key-name' +os.environ['ISS_KEY_TABLE_NAME'] = 'iss-key-table-name' +os.environ['ISS_SCMS_TOKEN_REST_ENDPOINT'] = 'iss-scms-token-rest-endpoint' +os.environ['ISS_SCMS_VEHICLE_REST_ENDPOINT'] = 'iss-scms-vehicle-rest-endpoint' +os.environ['ISS_PROJECT_ID'] = 'iss-project-id' diff --git a/services/addons/tests/iss_health_check/test_environment_iss_health_check.py b/services/addons/tests/iss_health_check/test_environment_iss_health_check.py new file mode 100644 index 000000000..6dd5ddd94 --- /dev/null +++ b/services/addons/tests/iss_health_check/test_environment_iss_health_check.py @@ -0,0 +1,35 @@ +import pytest +from addons.images.iss_health_check import iss_health_check_environment + + +def test_get_storage_type_gcp(): + actual_value = iss_health_check_environment.process_storage_type("gcp", "postgres") + assert actual_value == "gcp" + + +def test_get_storage_type_postgres(): + actual_value = iss_health_check_environment.process_storage_type( + "postgres", "postgres" + ) + assert actual_value == "postgres" + + +def test_get_storage_type_gcp_case_insensitive(): + actual_value = iss_health_check_environment.process_storage_type("GCP", "postgres") + assert actual_value == "gcp" + + +def test_get_storage_type_postgres_case_insensitive(): + actual_value = iss_health_check_environment.process_storage_type( + "POSTGRES", "postgres" + ) + assert actual_value == "postgres" + + +def test_get_storage_type_invalid(): + with pytest.raises(ValueError): + iss_health_check_environment.process_storage_type("test", "postgres") + + +def test_get_storage_type_unset(): + iss_health_check_environment.process_storage_type(None, "postgres") == "postgres" diff --git a/services/addons/tests/iss_health_check/test_iss_health_checker.py b/services/addons/tests/iss_health_check/test_iss_health_checker.py index 0e2069d32..658f5bcb9 100644 --- a/services/addons/tests/iss_health_check/test_iss_health_checker.py +++ b/services/addons/tests/iss_health_check/test_iss_health_checker.py @@ -1,5 +1,4 @@ from unittest.mock import patch -import os from addons.images.iss_health_check import iss_health_checker from addons.images.iss_health_check.iss_health_checker import RsuDataWrapper @@ -39,14 +38,12 @@ def test_get_rsu_data_with_data(mock_query_db): ) -@patch.dict( - os.environ, - { - "ISS_API_KEY": "test", - "ISS_SCMS_VEHICLE_REST_ENDPOINT": "https://api.dm.iss-scms.com/api/test", - "ISS_PROJECT_ID": "test", - }, +@patch("iss_health_check_environment.ISS_API_KEY", "test") +@patch( + "iss_health_check_environment.ISS_SCMS_VEHICLE_REST_ENDPOINT", + "https://api.dm.iss-scms.com/api/test", ) +@patch("iss_health_check_environment.ISS_PROJECT_ID", "test") @patch("addons.images.iss_health_check.iss_health_checker.requests.Response") @patch("addons.images.iss_health_check.iss_health_checker.requests") @patch("addons.images.iss_health_check.iss_health_checker.iss_token") @@ -205,4 +202,4 @@ def test_insert_scms_data_no_expiration(mock_write_db, mock_datetime): 'INSERT INTO public.scms_health("timestamp", health, expiration, rsu_id) VALUES ' "('2022-11-03T00:00:00.000Z', '0', 'test', 2)" ) - mock_write_db.assert_called_with(expectedQuery) \ No newline at end of file + mock_write_db.assert_called_with(expectedQuery) diff --git a/services/addons/tests/iss_health_check/test_iss_token.py b/services/addons/tests/iss_health_check/test_iss_token.py index 4e588dd6c..32334854a 100644 --- a/services/addons/tests/iss_health_check/test_iss_token.py +++ b/services/addons/tests/iss_health_check/test_iss_token.py @@ -1,77 +1,9 @@ from unittest.mock import patch, MagicMock -import os import json -import pytest - from addons.images.iss_health_check import iss_token -# --------------------- Storage Type tests --------------------- -@patch.dict( - os.environ, - { - "STORAGE_TYPE": "gcp", - }, -) -def test_get_storage_type_gcp(): - actual_value = iss_token.get_storage_type() - assert actual_value == "gcp" - - -@patch.dict( - os.environ, - { - "STORAGE_TYPE": "postgres", - }, -) -def test_get_storage_type_postgres(): - actual_value = iss_token.get_storage_type() - assert actual_value == "postgres" - - -@patch.dict( - os.environ, - { - "STORAGE_TYPE": "GCP", - }, -) -def test_get_storage_type_gcp_case_insensitive(): - actual_value = iss_token.get_storage_type() - assert actual_value == "gcp" - - -@patch.dict( - os.environ, - { - "STORAGE_TYPE": "POSTGRES", - }, -) -def test_get_storage_type_postgres_case_insensitive(): - actual_value = iss_token.get_storage_type() - assert actual_value == "postgres" - - -@patch.dict( - os.environ, - { - "STORAGE_TYPE": "test", - }, -) -def test_get_storage_type_invalid(): - with pytest.raises(SystemExit): - iss_token.get_storage_type() - - -@patch.dict(os.environ, {}, clear=True) -def test_get_storage_type_unset(): - with pytest.raises(SystemExit): - iss_token.get_storage_type() - - -# --------------------- end of Storage Type tests --------------------- - - # --------------------- GCP tests --------------------- @patch( "addons.images.iss_health_check.iss_token.secretmanager.SecretManagerServiceClient" @@ -103,7 +35,7 @@ def test_check_if_secret_exists_true(mock_secretmanager, mock_sm_client): ) mock_secretmanager.ListSecretsRequest.assert_called_with(parent="test-parent") mock_sm_client.list_secrets.assert_called_with(request="list-request") - assert actual_value == True + assert actual_value is True @patch( @@ -123,7 +55,7 @@ def test_check_if_secret_exists_false(mock_secretmanager, mock_sm_client): ) mock_secretmanager.ListSecretsRequest.assert_called_with(parent="test-parent") mock_sm_client.list_secrets.assert_called_with(request="list-request") - assert actual_value == False + assert actual_value is False @patch( @@ -159,16 +91,14 @@ def test_add_secret_version(mock_sm_client): mock_sm_client.add_secret_version.assert_called_with(request=expected_request) -@patch.dict( - os.environ, - { - "PROJECT_ID": "test-proj", - "ISS_API_KEY": "test-api-key", - "ISS_SCMS_TOKEN_REST_ENDPOINT": "https://api.dm.iss-scms.com/api/test-token", - "ISS_API_KEY_NAME": "test-api-key-name", - "STORAGE_TYPE": "gcp", - }, +@patch("iss_health_check_environment.PROJECT_ID", "test-proj") +@patch("iss_health_check_environment.ISS_API_KEY", "test-api-key") +@patch( + "iss_health_check_environment.ISS_SCMS_TOKEN_REST_ENDPOINT", + "https://api.dm.iss-scms.com/api/test-token", ) +@patch("iss_health_check_environment.ISS_API_KEY_NAME", "test-api-key-name") +@patch("iss_health_check_environment.STORAGE_TYPE", "gcp") @patch("addons.images.iss_health_check.iss_token.requests.Response") @patch("addons.images.iss_health_check.iss_token.requests") @patch("addons.images.iss_health_check.iss_token.uuid") @@ -224,16 +154,14 @@ def test_get_token_create_secret( assert actual_value == expected_value -@patch.dict( - os.environ, - { - "PROJECT_ID": "test-proj", - "ISS_API_KEY": "test-api-key", - "ISS_SCMS_TOKEN_REST_ENDPOINT": "https://api.dm.iss-scms.com/api/test-token", - "ISS_API_KEY_NAME": "test-api-key-name", - "STORAGE_TYPE": "gcp", - }, +@patch("iss_health_check_environment.PROJECT_ID", "test-proj") +@patch("iss_health_check_environment.ISS_API_KEY", "test-api-key") +@patch( + "iss_health_check_environment.ISS_SCMS_TOKEN_REST_ENDPOINT", + "https://api.dm.iss-scms.com/api/test-token", ) +@patch("iss_health_check_environment.ISS_API_KEY_NAME", "test-api-key-name") +@patch("iss_health_check_environment.STORAGE_TYPE", "gcp") @patch("addons.images.iss_health_check.iss_token.requests.Response") @patch("addons.images.iss_health_check.iss_token.requests") @patch("addons.images.iss_health_check.iss_token.uuid") @@ -312,7 +240,7 @@ def test_check_if_data_exists_true(mock_pgquery): actual_value = iss_token.check_if_data_exists("test-table-name") expected_query = "SELECT * FROM test-table-name" mock_pgquery.query_db.assert_called_with(expected_query) - assert actual_value == True + assert actual_value is True @patch( @@ -323,7 +251,7 @@ def test_check_if_data_exists_false(mock_pgquery): actual_value = iss_token.check_if_data_exists("test-table-name") expected_query = "SELECT * FROM test-table-name" mock_pgquery.query_db.assert_called_with(expected_query) - assert actual_value == False + assert actual_value is False @patch( @@ -349,17 +277,15 @@ def test_get_latest_data(mock_pgquery): assert actual_value == {"id": 1, "name": "test-common-name", "token": "test-token"} -@patch.dict( - os.environ, - { - "PROJECT_ID": "test-proj", - "ISS_API_KEY": "test-api-key", - "ISS_SCMS_TOKEN_REST_ENDPOINT": "https://api.dm.iss-scms.com/api/test-token", - "ISS_API_KEY_NAME": "test-api-key-name", - "STORAGE_TYPE": "postgres", - "ISS_KEY_TABLE_NAME": "test-table-name", - }, +@patch("iss_health_check_environment.PROJECT_ID", "test-proj") +@patch("iss_health_check_environment.ISS_API_KEY", "test-api-key") +@patch( + "iss_health_check_environment.ISS_SCMS_TOKEN_REST_ENDPOINT", + "https://api.dm.iss-scms.com/api/test-token", ) +@patch("iss_health_check_environment.ISS_API_KEY_NAME", "test-api-key-name") +@patch("iss_health_check_environment.STORAGE_TYPE", "postgres") +@patch("iss_health_check_environment.ISS_KEY_TABLE_NAME", "test-table-name") @patch("addons.images.iss_health_check.iss_token.requests.Response") @patch("addons.images.iss_health_check.iss_token.requests") @patch("addons.images.iss_health_check.iss_token.uuid") @@ -400,17 +326,15 @@ def test_get_token_data_does_not_exist( assert result == "new-iss-token" -@patch.dict( - os.environ, - { - "PROJECT_ID": "test-proj", - "ISS_API_KEY": "test-api-key", - "ISS_SCMS_TOKEN_REST_ENDPOINT": "https://api.dm.iss-scms.com/api/test-token", - "ISS_API_KEY_NAME": "test-api-key-name", - "STORAGE_TYPE": "postgres", - "ISS_KEY_TABLE_NAME": "test-table-name", - }, +@patch("iss_health_check_environment.PROJECT_ID", "test-proj") +@patch("iss_health_check_environment.ISS_API_KEY", "test-api-key") +@patch( + "iss_health_check_environment.ISS_SCMS_TOKEN_REST_ENDPOINT", + "https://api.dm.iss-scms.com/api/test-token", ) +@patch("iss_health_check_environment.ISS_API_KEY_NAME", "test-api-key-name") +@patch("iss_health_check_environment.STORAGE_TYPE", "postgres") +@patch("iss_health_check_environment.ISS_KEY_TABLE_NAME", "test-table-name") @patch("addons.images.iss_health_check.iss_token.requests.Response") @patch("addons.images.iss_health_check.iss_token.requests") @patch("addons.images.iss_health_check.iss_token.uuid") diff --git a/services/addons/tests/obu_ota_server/conftest.py b/services/addons/tests/obu_ota_server/conftest.py new file mode 100644 index 000000000..80e8561cf --- /dev/null +++ b/services/addons/tests/obu_ota_server/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ['OTA_PASSWORD'] = 'ota-password' diff --git a/services/addons/tests/obu_ota_server/test_obu_ota_server.py b/services/addons/tests/obu_ota_server/test_obu_ota_server.py index 675977d16..75deca05b 100644 --- a/services/addons/tests/obu_ota_server/test_obu_ota_server.py +++ b/services/addons/tests/obu_ota_server/test_obu_ota_server.py @@ -17,23 +17,21 @@ ) -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "DOCKER") @patch("glob.glob") -def test_get_firmware_list_local(mock_glob, mock_getenv): - mock_getenv.return_value = "DOCKER" +def test_get_firmware_list_local(mock_glob): mock_glob.return_value = ["/firmwares/test1.tar.sig", "/firmwares/test2.tar.sig"] result = get_firmware_list() - mock_getenv.assert_called_once_with("BLOB_STORAGE_PROVIDER", "DOCKER") mock_glob.assert_called_once_with("/firmwares/*.tar.sig") assert result == ["/firmwares/test1.tar.sig", "/firmwares/test2.tar.sig"] -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "GCP") +@patch("obu_ota_server_environment.BLOB_STORAGE_PATH", "PATH") @patch("common.gcs_utils.list_gcs_blobs") -def test_get_firmware_list_gcs(mock_list_gcs_blobs, mock_getenv): - mock_getenv.return_value = "GCP" +def test_get_firmware_list_gcs(mock_list_gcs_blobs): mock_list_gcs_blobs.return_value = [ "/firmwares/test1.tar.sig", "/firmwares/test2.tar.sig", @@ -41,56 +39,46 @@ def test_get_firmware_list_gcs(mock_list_gcs_blobs, mock_getenv): result = get_firmware_list() - # mock_getenv.assert_called_once_with("BLOB_STORAGE_PROVIDER", "DOCKER") - mock_list_gcs_blobs.assert_called_once_with("GCP", ".tar.sig") + mock_list_gcs_blobs.assert_called_once_with("PATH", ".tar.sig") assert result == ["/firmwares/test1.tar.sig", "/firmwares/test2.tar.sig"] -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "DOCKER") @patch("os.path.exists") @patch("common.gcs_utils.list_gcs_blobs") -def test_get_firmware_local_fail(mock_gcs_utils, mock_os_path_exists, mock_os_getenv): - mock_os_getenv.return_value = "DOCKER" +def test_get_firmware_local_fail(mock_gcs_utils, mock_os_path_exists): mock_os_path_exists.return_value = False firmware_id = "test_firmware_id" local_file_path = "test_local_file_path" result = get_firmware(firmware_id, local_file_path) - mock_os_getenv.assert_called_once_with("BLOB_STORAGE_PROVIDER", "DOCKER") mock_os_path_exists.assert_called_once_with(local_file_path) mock_gcs_utils.assert_not_called() - assert result == False + assert result is False -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "DOCKER") @patch("os.path.exists") @patch("common.gcs_utils.list_gcs_blobs") -def test_get_firmware_local_success( - mock_gcs_utils, mock_os_path_exists, mock_os_getenv -): - mock_os_getenv.return_value = "DOCKER" +def test_get_firmware_local_success(mock_gcs_utils, mock_os_path_exists): mock_os_path_exists.return_value = True firmware_id = "test_firmware_id" local_file_path = "test_local_file_path" result = get_firmware(firmware_id, local_file_path) - mock_os_getenv.assert_called_once_with("BLOB_STORAGE_PROVIDER", "DOCKER") mock_os_path_exists.assert_called_once_with(local_file_path) mock_gcs_utils.assert_not_called() - assert result == True + assert result is True -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "GCP") @patch("os.path.exists") @patch("common.gcs_utils.download_gcp_blob") -def test_get_firmware_gcs_success( - mock_download_gcp_blob, mock_os_path_exists, mock_os_getenv -): - mock_os_getenv.return_value = "GCP" +def test_get_firmware_gcs_success(mock_download_gcp_blob, mock_os_path_exists): mock_os_path_exists.return_value = False mock_download_gcp_blob.return_value = True @@ -99,22 +87,18 @@ def test_get_firmware_gcs_success( local_file_path = "test_local_file_path" result = get_firmware(firmware_id, local_file_path) - mock_os_getenv.assert_called_with("BLOB_STORAGE_PROVIDER", "DOCKER") mock_os_path_exists.assert_called_with(local_file_path) mock_download_gcp_blob.assert_called_once_with( firmware_id, local_file_path, firmware_file_ext ) - assert result == True + assert result is True -@patch("os.getenv") +@patch("obu_ota_server_environment.BLOB_STORAGE_PROVIDER", "GCP") @patch("os.path.exists") @patch("common.gcs_utils.download_gcp_blob") -def test_get_firmware_gcs_failure( - mock_download_gcp_blob, mock_os_path_exists, mock_os_getenv -): - mock_os_getenv.return_value = "GCP" +def test_get_firmware_gcs_failure(mock_download_gcp_blob, mock_os_path_exists): mock_os_path_exists.return_value = False mock_download_gcp_blob.return_value = False @@ -123,13 +107,12 @@ def test_get_firmware_gcs_failure( local_file_path = "test_local_file_path" result = get_firmware(firmware_id, local_file_path) - mock_os_getenv.assert_called_with("BLOB_STORAGE_PROVIDER", "DOCKER") mock_os_path_exists.assert_called_with(local_file_path) mock_download_gcp_blob.assert_called_once_with( firmware_id, local_file_path, firmware_file_ext ) - assert result == False + assert result is False def test_parse_range_header_valid(): @@ -198,7 +181,14 @@ async def test_read_file_no_end_range(): os.remove(temp_path) -@patch.dict("os.environ", {"OTA_USERNAME": "username", "OTA_PASSWORD": "password"}) +@patch( + "obu_ota_server_environment.OTA_USERNAME", + "username", +) +@patch( + "obu_ota_server_environment.OTA_PASSWORD", + "password", +) @pytest.mark.anyio async def test_read_root(): async with AsyncClient( @@ -209,10 +199,17 @@ async def test_read_root(): assert response.json() == {"message": "obu ota server healthcheck", "root_path": ""} -@patch.dict("os.environ", {"OTA_USERNAME": "username", "OTA_PASSWORD": "password"}) -@pytest.mark.anyio +@patch( + "obu_ota_server_environment.OTA_USERNAME", + "username", +) +@patch( + "obu_ota_server_environment.OTA_PASSWORD", + "password", +) @patch("addons.images.obu_ota_server.obu_ota_server.get_firmware_list") @patch("addons.images.obu_ota_server.obu_ota_server.commsignia_manifest.add_contents") +@pytest.mark.anyio async def test_get_manifest(mock_commsignia_manifest, mock_get_firmware_list): mock_get_firmware_list.return_value = [ "/firmwares/test1.tar.sig", @@ -229,11 +226,18 @@ async def test_get_manifest(mock_commsignia_manifest, mock_get_firmware_list): assert response.json() == {"json": "data"} -@patch.dict("os.environ", {"OTA_USERNAME": "username", "OTA_PASSWORD": "password"}) -@pytest.mark.anyio +@patch( + "obu_ota_server_environment.OTA_USERNAME", + "username", +) +@patch( + "obu_ota_server_environment.OTA_PASSWORD", + "password", +) @patch("addons.images.obu_ota_server.obu_ota_server.get_firmware") @patch("addons.images.obu_ota_server.obu_ota_server.parse_range_header") @patch("addons.images.obu_ota_server.obu_ota_server.read_file") +@pytest.mark.anyio async def test_get_fw(mock_read_file, mock_parse_range_header, mock_get_firmware): mock_get_firmware.return_value = True mock_parse_range_header.return_value = 0, 100 @@ -250,10 +254,10 @@ async def test_get_fw(mock_read_file, mock_parse_range_header, mock_get_firmware assert response.headers["Content-Length"] == "100" -@pytest.mark.asyncio @patch("addons.images.obu_ota_server.obu_ota_server.pgquery") @patch("addons.images.obu_ota_server.obu_ota_server.datetime") @patch("addons.images.obu_ota_server.obu_ota_server.removed_old_logs") +@pytest.mark.asyncio async def test_log_request(mock_removed_old_logs, mock_datetime, mock_pgquery): fixed_datetime = datetime(2024, 7, 30, 0, 0, 0) mock_datetime.now.return_value = fixed_datetime @@ -291,7 +295,7 @@ async def test_log_request(mock_removed_old_logs, mock_datetime, mock_pgquery): ) -@patch.dict("os.environ", {"MAX_COUNT": "10"}) +@patch("obu_ota_server_environment.MAX_COUNT", 10) @patch("addons.images.obu_ota_server.obu_ota_server.pgquery") def test_removed_old_logs_no_removal(mock_pgquery): mock_pgquery.query_db.side_effect = [ @@ -307,7 +311,7 @@ def test_removed_old_logs_no_removal(mock_pgquery): mock_pgquery.write_db.assert_not_called() -@patch.dict("os.environ", {"MAX_COUNT": "5"}) +@patch("obu_ota_server_environment.MAX_COUNT", 5) @patch("addons.images.obu_ota_server.obu_ota_server.pgquery") def test_removed_old_logs_with_removal(mock_pgquery): mock_pgquery.query_db.side_effect = [ @@ -330,10 +334,17 @@ def test_removed_old_logs_with_removal(mock_pgquery): ) -@patch.dict("os.environ", {"OTA_USERNAME": "username", "OTA_PASSWORD": "password"}) -@pytest.mark.anyio +@patch( + "obu_ota_server_environment.OTA_USERNAME", + "username", +) +@patch( + "obu_ota_server_environment.OTA_PASSWORD", + "password", +) @patch("addons.images.obu_ota_server.obu_ota_server.get_firmware_list") @patch("addons.images.obu_ota_server.obu_ota_server.commsignia_manifest.add_contents") +@pytest.mark.anyio async def test_get_manifest(mock_commsignia_manifest, mock_get_firmware_list): mock_get_firmware_list.return_value = [ "/firmwares/test1.tar.sig", @@ -350,17 +361,12 @@ async def test_get_manifest(mock_commsignia_manifest, mock_get_firmware_list): assert response.json() == {"json": "data"} -@patch.dict( - "os.environ", - { - "OTA_USERNAME": "username", - "OTA_PASSWORD": "password", - "NGINX_ENCRYPTION": "plain", - }, -) -@pytest.mark.anyio +@patch("obu_ota_server_environment.OTA_USERNAME", "username") +@patch("obu_ota_server_environment.OTA_PASSWORD", "password") +@patch("obu_ota_server_environment.NGINX_ENCRYPTION", "plain") @patch("addons.images.obu_ota_server.obu_ota_server.get_firmware_list") @patch("addons.images.obu_ota_server.obu_ota_server.commsignia_manifest.add_contents") +@pytest.mark.anyio async def test_fqdn_response_plain(mock_commsignia_manifest, mock_get_firmware_list): mock_get_firmware_list.return_value = [] expected_hostname = "http://localhost" @@ -377,17 +383,12 @@ async def test_fqdn_response_plain(mock_commsignia_manifest, mock_get_firmware_l mock_commsignia_manifest.assert_called_once_with(expected_hostname, []) -@patch.dict( - "os.environ", - { - "OTA_USERNAME": "username", - "OTA_PASSWORD": "password", - "NGINX_ENCRYPTION": "SSL", - }, -) -@pytest.mark.anyio +@patch("obu_ota_server_environment.OTA_USERNAME", "username") +@patch("obu_ota_server_environment.OTA_PASSWORD", "password") +@patch("obu_ota_server_environment.NGINX_ENCRYPTION", "ssl") @patch("addons.images.obu_ota_server.obu_ota_server.get_firmware_list") @patch("addons.images.obu_ota_server.obu_ota_server.commsignia_manifest.add_contents") +@pytest.mark.anyio async def test_fqdn_response_ssl(mock_commsignia_manifest, mock_get_firmware_list): mock_get_firmware_list.return_value = [] expected_hostname = "https://localhost" diff --git a/services/addons/tests/rsu_status_check/conftest.py b/services/addons/tests/rsu_status_check/conftest.py new file mode 100644 index 000000000..25727936d --- /dev/null +++ b/services/addons/tests/rsu_status_check/conftest.py @@ -0,0 +1,5 @@ +import os + +os.environ['ZABBIX_ENDPOINT'] = 'zabbix-endpoint' +os.environ['ZABBIX_USER'] = 'zabbix-user' +os.environ['ZABBIX_PASSWORD'] = 'zabbix-password' diff --git a/services/addons/tests/rsu_status_check/test_rsu_msgfwd_fetch.py b/services/addons/tests/rsu_status_check/test_rsu_msgfwd_fetch.py index aeba21915..c9ca516f8 100644 --- a/services/addons/tests/rsu_status_check/test_rsu_msgfwd_fetch.py +++ b/services/addons/tests/rsu_status_check/test_rsu_msgfwd_fetch.py @@ -2,7 +2,7 @@ from addons.images.rsu_status_check.rsu_msgfwd_fetch import main -@patch("os.environ", {"RSU_MSGFWD_FETCH": "False"}) +@patch("rsu_status_check_environment.RSU_MSGFWD_FETCH", False) @patch( "addons.images.rsu_status_check.rsu_msgfwd_fetch.UpdatePostgresRsuMessageForward" ) @@ -15,7 +15,7 @@ def test_main_service_disabled(mock_logging, mock_update_pg): mock_update_pg.assert_not_called() -@patch("os.environ", {"RSU_MSGFWD_FETCH": "True"}) +@patch("rsu_status_check_environment.RSU_MSGFWD_FETCH", True) @patch( "addons.images.rsu_status_check.rsu_msgfwd_fetch.UpdatePostgresRsuMessageForward" ) diff --git a/services/addons/tests/rsu_status_check/test_rsu_ping_fetch.py b/services/addons/tests/rsu_status_check/test_rsu_ping_fetch.py index 7c0b0880a..499ec7278 100644 --- a/services/addons/tests/rsu_status_check/test_rsu_ping_fetch.py +++ b/services/addons/tests/rsu_status_check/test_rsu_ping_fetch.py @@ -78,12 +78,12 @@ def test_insert_rsu_ping(mock_write_db): def createRsuStatusFetchInstance(): - rsu_ping_fetch.os.environ["ZABBIX_ENDPOINT"] = "endpoint" - rsu_ping_fetch.os.environ["ZABBIX_USER"] = "user" - rsu_ping_fetch.os.environ["ZABBIX_PASSWORD"] = "password" return rsu_ping_fetch.RsuStatusFetch() +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_setZabbixAuth(): # prepare rsf = createRsuStatusFetchInstance() @@ -110,6 +110,9 @@ def test_setZabbixAuth(): assert rsf.ZABBIX_AUTH == "auth" +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_getHostInfo(): # prepare rsf = createRsuStatusFetchInstance() @@ -139,6 +142,9 @@ def test_getHostInfo(): assert result == {"result": "result"} +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_getItem(): # prepare rsf = createRsuStatusFetchInstance() @@ -170,6 +176,9 @@ def test_getItem(): assert result == {"result": "result"} +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_getHistory(): # prepare rsf = createRsuStatusFetchInstance() @@ -207,6 +216,9 @@ def test_getHistory(): assert result == {"result": "result"} +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_insertHistoryItem(): # prepare rsf = createRsuStatusFetchInstance() @@ -236,6 +248,9 @@ def test_insertHistoryItem(): assert result == True +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_printConfigInfo(): # prepare rsf = createRsuStatusFetchInstance() @@ -250,6 +265,9 @@ def test_printConfigInfo(): rsu_ping_fetch.logging.info.assert_called_once_with(expected_message) +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_run(): # prepare rsf = createRsuStatusFetchInstance() @@ -281,6 +299,9 @@ def test_run(): rsu_ping_fetch.logging.error.assert_not_called() +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_run_insert_failure(): # prepare rsf = createRsuStatusFetchInstance() @@ -314,6 +335,9 @@ def test_run_insert_failure(): rsu_ping_fetch.logging.error.assert_not_called() +@patch("rsu_status_check_environment.ZABBIX_ENDPOINT", "endpoint") +@patch("rsu_status_check_environment.ZABBIX_USER", "user") +@patch("rsu_status_check_environment.ZABBIX_PASSWORD", "password") def test_run_exception(): # prepare rsf = createRsuStatusFetchInstance() diff --git a/services/api/README.md b/services/api/README.md index ac963eddd..cf616d110 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -22,20 +22,7 @@ The middleware makes the following assumptions: Expected headers for all endpoints: - `"Content-Type": "application/json"` -- `"Authorization": "tokenId"` - -### /user-auth (GET) - -Returns authorized user information including full name, email, and role. - -Example return value: - -- {"name": "John Doe", "email": "jdoe@gmail.com", "role": "admin"} - -### /contact-support (POST) - -Sends a support request email to all users subscribed to 'Support Requests' in the cv-manager. Please note that this functionality -relies on the user_email_notification table in PostgreSQL to pull in all users subscribed to receive these notifications. +- `"Authorization": "token"` ### /rsuinfo (GET) @@ -143,119 +130,6 @@ body example: } ``` -### /admin-rsu (GET) - -Depending upon the rsu_ip argument's value, this endpoint returns a list of all RSUs in the CV Manager's PostgreSQL DB or the details of a single RSU along with the options for specific RSU fields that do not take free-form responses. - -HTTP URL Arguments: - -- rsu_ip: - - Set to "all" if you want a list of all RSUs regardless of organization affiliation. Will not return the RSU field options. - - Set to a specific RSU IP such as "10.0.0.1" to return all of the RSU details of that single RSU along with the allowed RSU field options. - -### /admin-rsu (PATCH) - -Modifies an RSU within the CV Manager database, including RSUs that may not have been made through the /admin-new-rsu endpoint. Currently supports Commsignia, Kapsch and Yunex. - -body example: - -``` -{ - "ip": "10.0.0.1", - "geo_position": { - "latitude": 40.00, - "longitude": -100.00 - }, - "milepost": 56.8, - "primary_route": "I25", - "serial_number": "55EE002211", - "model": "Commsignia", - "scms_id": "", - "ssh_credential_group": "ssh profile", - "snmp_credential_group": "snmp profile", - "snmp_version_group": "snmp version", - "organizations_to_add": ["Organization 1"], - "organizations_to_remove": [] -} -``` - -### /admin-rsu (DELETE) - -Deletes the specified RSU from the CV Manager PostgreSQL database based off the IP specified in the rsu_ip argument. - -HTTP URL Arguments: - -- rsu_ip: Delete a specific RSU specified by its IP such as "10.0.0.1" from the CV Manager's PostgreSQL database. - -## Users - -### /admin-new-user (GET) - -Returns the field options for specific user fields that do not take free-form responses. - -- organizations -- roles - -### /admin-new-user (POST) - -Adds a new user to the CV Manager database. Associates the user with every organization specified. The specified user will be able to login to the CV Manager as soon as this is complete. The email associated with the user MUST be a Gmail account or an email address that is an alias of a Gmail. - -body example: - -``` -{ - "email": "jdoe@example.com", - "first_name": "John", - "last_name": "Doe", - "super_user": True, - "organizations": [ - {"name": "Test Org", "role": "operator"} - ] -} -``` - -### /admin-user (GET) - -Depending upon the user_email argument's value, this endpoint returns a list of all users in the CV Manager's PostgreSQL DB or the details of a single user along with the options for specific user fields that do not take free-form responses. - -HTTP URL Arguments: - -- user_email: - - Set to "all" if you want a list of all users regardless of organization affiliation. Will not return the user field options. - - Set to a specific user email such as "user@email.com" to return all of the user details of that single user along with the allowed user field options. - -### /admin-user (PATCH) - -Modifies a user within the CV Manager database, including users that may not have been made through the /admin-new-user endpoint. - -body example: - -``` -{ - "email": "jdoe@example.com", - "first_name": "John", - "last_name": "Doe", - "super_user": True, - "organizations_to_add": [ - {"name": "Test Org3", "role": "admin"} - ], - "organizations_to_modify": [ - {"name": "Test Org2", "role": "user"} - ], - "organizations_to_remove": [ - {"name": "Test Org", "role": "user"} - ] -} -``` - -### /admin-user (DELETE) - -Deletes the specified user from the CV Manager PostgreSQL database based off the user email specified in the user_email argument. - -HTTP URL Arguments: - -- user_email: Delete a specific user specified by its email such as "user@email.com" from the CV Manager's PostgreSQL database. - ## Organizations ### /admin-new-org (POST) @@ -335,11 +209,10 @@ HTTP URL Arguments: - PG_DB_PORT: The database port. - PG_PG_DB_USER: The database user that will be used to authenticate the cloud function when it queries the database. - PG_PG_DB_PASS: The database user's password that will be used to authenticate the cloud function. -- COUNTS_MSG_TYPES: Set to a list of message types to include in counts query. Sample format is described in the sample.env. - MONGO_PROCESSED_BSM_COLLECTION_NAME: The database name for processed BSM messages output from the [Geojson Converter](https://github.com/usdot-jpo-ode/geojson-converter). - MONGO_PROCESSED_PSM_COLLECTION_NAME: The database name for processed PSM messages output from the [Geojson Converter](https://github.com/usdot-jpo-ode/geojson-converter). -- SSM_DB_NAME: The database name for SSM visualization data. -- SRM_DB_NAME: The database name for SRM visualization data. +- MONGO_SSM_COLLECTION_NAME: The database name for SSM visualization data. +- MONGO_SRM_COLLECTION_NAME: The database name for SRM visualization data. - MONGO_DB_URI: URI for the MongoDB connection. - MONGO_DB_NAME: Database name for RSU counts. - KEYCLOAK_ENDPOINT: Keycloak base URL to send requests to. Reference the sample.env for the URL formatting. @@ -355,10 +228,6 @@ HTTP URL Arguments: - CSM_TARGET_SMTP_SERVER_PORT: Destination SMTP server port. - WZDX_ENDPOINT: WZDX datafeed enpoint. - WZDX_API_KEY: API key for the WZDX datafeed. -- GOOGLE_ACCESS_KEY_NAME: The required Google environment variable for authenticating with Google Cloud. -- GCP_PROJECT_ID: The Google Cloud project ID for which the service account associated with GOOGLE_ACCESS_KEY_NAME is for. -- MOOVE_AI_SEGMENT_AGG_STATS_TABLE: The BigQuery table name for Moove.Ai's segment aggregate statistics. -- MOOVE_AI_SEGMENT_EVENT_STATS_TABLE: The BigQuery table name for Moove.Ai's segment event statistics. - TIMEZONE: Timezone to be used for the API. 1. Configure the Cloud Run deployment connections settings diff --git a/services/api/requirements.txt b/services/api/requirements.txt index 1a8710b78..567e90c8f 100644 --- a/services/api/requirements.txt +++ b/services/api/requirements.txt @@ -10,7 +10,7 @@ DateTime==5.2 python-dateutil==2.8.2 pytz==2023.3.post1 Werkzeug==3.0.0 -python-keycloak==5.5.1 +python-keycloak==5.8.1 pymongo==4.5.0 fabric==3.2.2 google-cloud-bigquery==3.29.0 diff --git a/services/api/sample.env b/services/api/sample.env index d56235702..52fce010a 100644 --- a/services/api/sample.env +++ b/services/api/sample.env @@ -3,49 +3,53 @@ # If using docker then this value should be set to: http://${WEBAPP_HOST_IP}:3000 # If running the webapp using npm then set it to: http://localhost:3000 # Leave as * to allow all domains access -CORS_DOMAIN = * +CORS_DOMAIN=* # PostgreSQL Database connection information # this value may need to folow with the webapp host if debugging the applications -PG_DB_HOST=:5432 -PG_DB_NAME= -PG_DB_USER= +PG_DB_HOST=localhost:5432 +PG_DB_NAME=postgres +PG_DB_USER=postgres # If the PG_DB_PASS variable has special characters, make sure to wrap it in single quotes -PG_DB_PASS= +PG_DB_PASS=postgres # If connecting to PGDB over websocket: INSTANCE_CONNECTION_NAME= # Keycloak Variables -KEYCLOAK_ENDPOINT= http://cvmanager.auth.com:8084/ -KEYCLOAK_REALM= -KEYCLOAK_API_CLIENT_ID= -KEYCLOAK_API_CLIENT_SECRET_KEY= +KEYCLOAK_ENDPOINT=http://localhost:8084 +KEYCLOAK_REALM=cvmanager +KEYCLOAK_API_CLIENT_ID=cvmanager-api +KEYCLOAK_API_CLIENT_SECRET_KEY=keycloak-secret-key # Firmware Manager connectivity in the format 'http://endpoint:port' -FIRMWARE_MANAGER_ENDPOINT=http://:8089 +FIRMWARE_MANAGER_ENDPOINT=http://localhost:8089 # If "BIGQUERY", set the location of the GCP service account key GOOGLE_APPLICATION_CREDENTIALS='./resources/google/sample_gcp_service_account.json' # If "MONGODB", MongoDB variables -MONGO_DB_URI= -MONGO_DB_NAME="ODE" +MONGO_DB_URI=mongodb://ode:replace-me@localhost:27017/?directConnection=true&authSource=admin +MONGO_DB_NAME="CV" # Set these variables if using either "MONGODB" or "BIGQUERY" -# COUNTS_MSG_TYPES: Comma seperated list of message types -COUNTS_MSG_TYPES='BSM,SSM,SPAT,SRM,MAP' -MONGO_PROCESSED_BSM_COLLECTION_NAME="ProcessedBsm" -MONGO_PROCESSED_PSM_COLLECTION_NAME="ProcessedPsm" -SSM_DB_NAME= -SRM_DB_NAME= +MONGO_PROCESSED_BSM_COLLECTION_NAME="processed_bsm" +MONGO_PROCESSED_PSM_COLLECTION_NAME="processed_psm" +MONGO_SSM_COLLECTION_NAME= +MONGO_SRM_COLLECTION_NAME= # Specifies the maximum number of V2x messages returned from the geo_query_geo_data_mongo method before filtering occurs MAX_GEO_QUERY_RECORDS= +# Feature Flags +# Disable WZDx without WZDx Endpoint +ENABLE_RSU_FEATURES=true +ENABLE_INTERSECTION_FEATURES=true +ENABLE_WZDX_FEATURES=false + # WZDX Variables -WZDX_API_KEY = -WZDX_ENDPOINT = +WZDX_API_KEY= +WZDX_ENDPOINT= # Contact Support Menu Email Configuration CSM_EMAIL_TO_SEND_FROM= diff --git a/services/api/src/admin_email_notification.py b/services/api/src/admin_email_notification.py index c7640efa7..67f69e2aa 100644 --- a/services/api/src/admin_email_notification.py +++ b/services/api/src/admin_email_notification.py @@ -4,8 +4,8 @@ import urllib.request import logging import common.pgquery as pgquery +import api_environment from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os from werkzeug.exceptions import InternalServerError, BadRequest from common.auth_tools import ( @@ -157,14 +157,14 @@ class AdminNotificationPatchSchema(Schema): class AdminNotification(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "GET,PATCH,DELETE", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/admin_intersection.py b/services/api/src/admin_intersection.py deleted file mode 100644 index 9e832cd75..000000000 --- a/services/api/src/admin_intersection.py +++ /dev/null @@ -1,432 +0,0 @@ -from typing import Any -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import admin_new_intersection -import os -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - RESOURCE_TYPE, - EnvironWithOrg, - PermissionResult, - enforce_organization_restrictions, - require_permission, - generate_sql_placeholders_for_list, -) - - -def get_intersection_data( - intersection_id: str, user: EnvironWithOrg, qualified_orgs: list[str] -): - """ - Retrieve intersection data from the database for a given intersection ID or all intersections. - - Args: - intersection_id (str): The ID of the intersection to retrieve data for. - Use "all" to retrieve data for all intersections. - user (EnvironWithOrg): The user object containing organizational context and permissions. - qualified_orgs (list[str]): A list of organizations the user is qualified to access. - - Returns: - dict or list[dict]: - - If a single intersection ID is provided and found, returns a dictionary containing the intersection data. - - If "all" is provided, returns a list of dictionaries containing data for all intersections. - - If no data is found for a single intersection ID, returns an empty dictionary. - - Raises: - Exception: If there is an issue querying the database or processing the data. - """ - query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT intersection_number, ST_X(ref_pt::geometry) AS ref_pt_longitude, ST_Y(ref_pt::geometry) AS ref_pt_latitude, " - "ST_XMin(bbox::geometry) AS bbox_longitude_1, ST_YMin(bbox::geometry) AS bbox_latitude_1, " - "ST_XMax(bbox::geometry) AS bbox_longitude_2, ST_YMax(bbox::geometry) AS bbox_latitude_2, " - "intersection_name, origin_ip, " - "org.name AS org_name, rsu.ipv4_address AS rsu_ip " - "FROM public.intersections " - "JOIN public.intersection_organization AS ro ON ro.intersection_id = intersections.intersection_id " - "JOIN public.organizations AS org ON org.organization_id = ro.organization_id " - "LEFT JOIN public.rsu_intersection AS ri ON ri.intersection_id = intersections.intersection_id " - "LEFT JOIN public.rsus AS rsu ON rsu.rsu_id = ri.rsu_id " - ) - - where_clauses = [] - params: dict[str, Any] = {} - if not user.user_info.super_user: - org_names_placeholder, _ = generate_sql_placeholders_for_list( - qualified_orgs, params_to_update=params - ) - where_clauses.append(f"org.name IN ({org_names_placeholder})") - - if intersection_id != "all": - where_clauses.append("intersection_number = :intersection_id") - params["intersection_id"] = intersection_id - if where_clauses: - query += "WHERE " + " AND ".join(where_clauses) - query += ") as row" - - data = pgquery.query_db(query, params=params) - - intersection_dict = {} - for row in data: - row = dict(row[0]) - if str(row["intersection_number"]) not in intersection_dict: - intersection_dict[str(row["intersection_number"])] = { - "intersection_id": str(row["intersection_number"]), - "ref_pt": { - "latitude": row["ref_pt_latitude"], - "longitude": row["ref_pt_longitude"], - }, - "bbox": { - "latitude1": row["bbox_latitude_1"], - "longitude1": row["bbox_longitude_1"], - "latitude2": row["bbox_latitude_2"], - "longitude2": row["bbox_longitude_2"], - }, - "intersection_name": row["intersection_name"], - "origin_ip": row["origin_ip"], - "organizations": [], - "rsus": [], - } - orgs = intersection_dict[str(row["intersection_number"])]["organizations"] - rsus = intersection_dict[str(row["intersection_number"])]["rsus"] - if row["org_name"] not in orgs: - orgs.append(row["org_name"]) - if row["rsu_ip"] not in rsus and row["rsu_ip"] is not None: - rsus.append(row["rsu_ip"]) - - intersection_list = list(intersection_dict.values()) - # If list is empty and a single Intersection was requested, return empty object - if len(intersection_list) == 0 and intersection_id != "all": - return {} - # If list is not empty and a single Intersection was requested, return the first index of the list - elif len(intersection_list) == 1 and intersection_id != "all": - return intersection_list[0] - else: - return intersection_list - - -def get_modify_intersection_data( - intersection_id: str, user: EnvironWithOrg, qualified_orgs: list[str] -): - """ - Retrieve and modify intersection summary data for a given intersection ID. - - Args: - intersection_id (str): The ID of the intersection to retrieve data for. - Use "all" to retrieve data for all intersections. - user (EnvironWithOrg): The user object containing organizational context. - qualified_orgs (list[str]): A list of organizations the user is qualified to access. - - Returns: - dict: A dictionary containing intersection data (and allowed selections - if a specific intersection ID is provided). - """ - modify_intersection_obj = {} - modify_intersection_obj["intersection_data"] = get_intersection_data( - intersection_id, user, qualified_orgs - ) - if intersection_id != "all": - modify_intersection_obj["allowed_selections"] = ( - admin_new_intersection.get_allowed_selections(user) - ) - return modify_intersection_obj - - -@require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, resource_type=RESOURCE_TYPE.INTERSECTION -) -def modify_intersection_authorized( - permission_result: PermissionResult, intersection_id: str, intersection_spec: dict -): - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=intersection_spec, - keys_to_check=["organizations_to_add", "organizations_to_remove"], - ) - - # Check for special characters for potential SQL injection - if not admin_new_intersection.check_safe_input(intersection_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - try: - # Modify the existing Intersection data - query = ( - "UPDATE public.intersections SET " - "intersection_number=:intersection_id, " - "ref_pt=ST_GeomFromText('POINT(' || :ref_pt_longitude || ' ' || :ref_pt_latitude || ')')" - ) - params = { - "intersection_id": intersection_id, - "ref_pt_longitude": intersection_spec["ref_pt"]["longitude"], - "ref_pt_latitude": intersection_spec["ref_pt"]["latitude"], - } - if "bbox" in intersection_spec: - query += ", bbox=ST_MakeEnvelope(:bbox_longitude1,:bbox_latitude1,:bbox_longitude2,:bbox_latitude2)" - params["bbox_longitude1"] = intersection_spec["bbox"]["longitude1"] - params["bbox_latitude1"] = intersection_spec["bbox"]["latitude1"] - params["bbox_longitude2"] = intersection_spec["bbox"]["longitude2"] - params["bbox_latitude2"] = intersection_spec["bbox"]["latitude2"] - if "intersection_name" in intersection_spec: - query += ", intersection_name=:intersection_name" - params["intersection_name"] = intersection_spec.get("intersection_name", "") - if "origin_ip" in intersection_spec: - query += ", origin_ip=:origin_ip" - params["origin_ip"] = intersection_spec.get("origin_ip", "") - query += " WHERE intersection_number=:orig_intersection_id" - params["orig_intersection_id"] = intersection_spec["orig_intersection_id"] - pgquery.write_db(query, params=params) - - # Add the intersection-to-organization relationships for the organizations to add - if len(intersection_spec["organizations_to_add"]) > 0: - query_rows: list[tuple[str, dict]] = [] - for index, organization in enumerate( - intersection_spec["organizations_to_add"] - ): - org_placeholder = f"org_name_{index}" - query_rows.append( - ( - "(" - "(SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id), " - f"(SELECT organization_id FROM public.organizations WHERE name = :{org_placeholder})" - ")", - {org_placeholder: organization}, - ) - ) - - query_prefix = "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES " - pgquery.write_db_batched( - query_prefix, - query_rows, - base_params={"intersection_id": intersection_id}, - ) - - # Remove the intersection-to-organization relationships for the organizations to remove - if len(intersection_spec["organizations_to_remove"]) > 0: - params = {"intersection_id": intersection_id} - # Generate placeholders for each organization name - org_placeholders = [] - for idx, org in enumerate(intersection_spec["organizations_to_remove"]): - key = f"org_name_{idx}" - org_placeholders.append(f":{key}") - params[key] = org - - org_remove_query = ( - "DELETE FROM public.intersection_organization WHERE " - "intersection_id = (SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id) " - f"AND organization_id IN (SELECT organization_id FROM public.organizations WHERE name IN ({', '.join(org_placeholders)}))" - ) - pgquery.write_db(org_remove_query, params=params) - - # Add the rsu-to-intersection relationships for the rsus to add - if len(intersection_spec["rsus_to_add"]) > 0: - query_rows = [] - for index, rsu_ip in enumerate(intersection_spec["rsus_to_add"]): - ip_placeholder = f"rsu_ip_{index}" - query_rows.append( - ( - "(" - f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :{ip_placeholder}), " - "(SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id)" - ")", - {ip_placeholder: rsu_ip}, - ) - ) - - query_prefix = ( - "INSERT INTO public.rsu_intersection(rsu_id, intersection_id) VALUES " - ) - pgquery.write_db_batched( - query_prefix, - query_rows, - base_params={"intersection_id": intersection_id}, - ) - - # Remove the rsu-to-intersection relationships for the rsus to remove - if len(intersection_spec["rsus_to_remove"]) > 0: - params = {"intersection_id": intersection_id} - # Generate placeholders for each rsu IP - ip_placeholders = [] - for idx, rsu_ip in enumerate(intersection_spec["rsus_to_remove"]): - key = f"rsu_ip_{idx}" - ip_placeholders.append(f":{key}") - params[key] = rsu_ip - - rsu_remove_query = ( - "DELETE FROM public.rsu_intersection WHERE " - "intersection_id = (SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id) " - f"AND rsu_id IN (SELECT rsu_id FROM public.rsus WHERE ipv4_address IN ({', '.join(ip_placeholders)}))" - ) - pgquery.write_db(rsu_remove_query, params=params) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "Intersection successfully modified"} - - -@require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - resource_type=RESOURCE_TYPE.INTERSECTION, -) -def delete_intersection_authorized(intersection_id: str): - - # Delete Intersection to Organization relationships - org_remove_query = ( - "DELETE FROM public.intersection_organization WHERE " - "intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id)" - ) - pgquery.write_db(org_remove_query, params={"intersection_id": intersection_id}) - - rsu_intersection_remove_query = ( - "DELETE FROM public.rsu_intersection WHERE " - "intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = :intersection_id)" - ) - pgquery.write_db( - rsu_intersection_remove_query, params={"intersection_id": intersection_id} - ) - - # Delete Intersection data - intersection_remove_query = ( - "DELETE FROM public.intersections WHERE intersection_number = :intersection_id" - ) - pgquery.write_db( - intersection_remove_query, params={"intersection_id": intersection_id} - ) - - return {"message": "Intersection successfully deleted"} - - -# REST endpoint resource class -class AdminIntersectionGetAllSchema(Schema): - intersection_id = fields.Str(required=True) - - -class AdminIntersectionGetDeleteSchema(Schema): - intersection_id = fields.Str(required=True) - - -class GeoPositionSchema(Schema): - latitude = fields.Decimal(required=True) - longitude = fields.Decimal(required=True) - - -class GeoPolygonSchema(Schema): - latitude1 = fields.Decimal(required=True) - longitude1 = fields.Decimal(required=True) - latitude2 = fields.Decimal(required=True) - longitude2 = fields.Decimal(required=True) - - -class AdminIntersectionPatchSchema(Schema): - orig_intersection_id = fields.Integer(required=True) - intersection_id = fields.Integer(required=True) - ref_pt = fields.Nested(GeoPositionSchema, required=True) - bbox = fields.Nested(GeoPolygonSchema, required=False) - intersection_name = fields.String(required=False) - origin_ip = fields.IPv4(required=False) - organizations_to_add = fields.List(fields.String(), required=True) - organizations_to_remove = fields.List(fields.String(), required=True) - rsus_to_add = fields.List(fields.String(), required=True) - rsus_to_remove = fields.List(fields.String(), required=True) - - -class AdminIntersection(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,PATCH,DELETE", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.USER, - ) - def get(self, permission_result: PermissionResult): - logging.debug("AdminIntersection GET requested") - - schema = AdminIntersectionGetAllSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - # If intersection_id is "all", allow without checking for an IPv4 address - if request.args["intersection_id"] != "all": - schema = AdminIntersectionGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - return ( - get_modify_intersection_data( - request.args["intersection_id"], - permission_result.user, - permission_result.qualified_orgs, - ), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.OPERATOR) - def patch(self): - logging.debug("AdminIntersection PATCH requested") - - # Check for main body values - schema = AdminIntersectionPatchSchema() - errors = schema.validate(request.json) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - - return ( - modify_intersection_authorized( - intersection_id=request.json.get("intersection_id"), - intersection_spec=request.json, - ), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.OPERATOR) - def delete(self): - logging.debug("AdminIntersection DELETE requested") - - schema = AdminIntersectionGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - return ( - delete_intersection_authorized(request.args["intersection_id"]), - 200, - self.headers, - ) diff --git a/services/api/src/admin_new_email_notification.py b/services/api/src/admin_new_email_notification.py index 51ac1f35c..483c3a24c 100644 --- a/services/api/src/admin_new_email_notification.py +++ b/services/api/src/admin_new_email_notification.py @@ -3,8 +3,8 @@ from marshmallow import Schema, fields import logging import common.pgquery as pgquery +import api_environment from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os from werkzeug.exceptions import InternalServerError, BadRequest from common.auth_tools import ( ORG_ROLE_LITERAL, @@ -99,14 +99,14 @@ class AdminGetNotificationSchema(Schema): class AdminNewNotification(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "GET,POST", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/admin_new_intersection.py b/services/api/src/admin_new_intersection.py deleted file mode 100644 index fc4bcb2f4..000000000 --- a/services/api/src/admin_new_intersection.py +++ /dev/null @@ -1,286 +0,0 @@ -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields, validate -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - EnvironWithOrg, - enforce_organization_restrictions, - get_qualified_org_list, - get_rsu_set_for_org, - require_permission, - PermissionResult, -) - - -def get_allowed_selections(user: EnvironWithOrg): - """ - Retrieve the list of allowed organizations and RSUs for the given user. - - This function determines the organizations and RSUs (Roadside Units) that the user - is authorized to access. If the user is a superuser, all organizations and RSUs are - returned. Otherwise, only the organizations and RSUs associated with the user's - permissions are included. - - Args: - user (EnvironWithOrg): The user object containing organizational context and permissions. - - Returns: - dict: A dictionary containing the allowed organizations and RSUs for the user. - Example: - { - "organizations": ["Org A", "Org B"], - "rsus": ["192.168.1.1", "192.168.1.2"] - } - """ - allowed = {} - - if user.user_info.super_user: - organizations_query = "SELECT name FROM public.organizations ORDER BY name ASC" - allowed["organizations"] = pgquery.query_and_return_list(organizations_query) - - rsus_query = "SELECT CAST(ipv4_address AS TEXT) FROM public.rsus ORDER BY ipv4_address ASC" - allowed["rsus"] = pgquery.query_and_return_list(rsus_query) - else: - allowed["organizations"] = get_qualified_org_list( - user, ORG_ROLE_LITERAL.OPERATOR, include_super_user=False - ) - allowed["rsus"] = list(get_rsu_set_for_org(allowed["organizations"])) - - return allowed - - -def check_safe_input(intersection_spec): - """ - Validate the intersection specification for unsafe characters. - - This function checks the intersection specification for special characters or sequences - that could indicate potential SQL injection or other unsafe input. If any unsafe input - is detected, the function returns False. - - ##Note - Jacob Frye, 2025/07/02, allowing & character for intersection names - - Args: - intersection_spec (dict): A dictionary containing the intersection specification. - - Returns: - bool: True if the input is safe, False otherwise. - """ - special_characters = "!\"#$%'()*+,./:;<=>?@[\\]^`{|}~" - unchecked_fields = [ - "origin_ip", - "rsus", - "rsus_to_add", - "rsus_to_remove", - "latitude", - "longitude", - "latitude1", - "longitude1", - "latitude2", - "longitude2", - ] - for k, value in intersection_spec.items(): - if isinstance(value, dict): - if not check_safe_input(value): - return False - elif isinstance(value, list): - if not all(check_safe_input({k: v}) for v in value): - return False - else: - if (k in unchecked_fields) or (value is None): - continue - if any(c in special_characters for c in str(value)) or "--" in str(value): - logging.debug("Unsafe input detected in field '%s': %s", k, value) - return False - return True - - -def add_intersection(intersection_spec: dict): - """ - Add a new intersection to the database. - - This function inserts a new intersection into the database, including its associated - organizations and RSUs (Roadside Units). It validates the input for unsafe characters - and handles database integrity and SQL errors. - - Args: - intersection_spec (dict): A dictionary containing the intersection specification. - Example: - { - "intersection_id": "123", - "ref_pt": {"latitude": 40.123, "longitude": -105.456}, - "bbox": { - "latitude1": 40.111, - "longitude1": -105.444, - "latitude2": 40.133, - "longitude2": -105.466 - }, - "intersection_name": "Main St Intersection", - "origin_ip": "192.168.1.1", - "organizations": ["Org A", "Org B"], - "rsus": ["192.168.1.2", "192.168.1.3"] - } - - Returns: - dict: A success message indicating the intersection was added. - Example: {"message": "New Intersection successfully added"} - - Raises: - BadRequest: If the input contains unsafe characters. - InternalServerError: If a database integrity or SQL error occurs. - """ - # Check for special characters for potential SQL injection - if not check_safe_input(intersection_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - try: - query = "INSERT INTO public.intersections(intersection_number, ref_pt" - - # Add optional fields if they are present - if "bbox" in intersection_spec: - query += ", bbox" - if "intersection_name" in intersection_spec: - query += ", intersection_name" - if "origin_ip" in intersection_spec: - query += ", origin_ip" - - # Close the column list and start the VALUES clause - query += ") VALUES (" - - # Add the mandatory fields - query += ( - f"'{intersection_spec['intersection_id']}', " - f"ST_GeomFromText('POINT(' || {str(intersection_spec['ref_pt']['longitude'])} || ' ' || {str(intersection_spec['ref_pt']['latitude'])} || ')')" - ) - - # Add optional values if they are present - if "bbox" in intersection_spec: - query += ( - f", ST_MakeEnvelope({str(intersection_spec['bbox']['longitude1'])}," - f"{str(intersection_spec['bbox']['latitude1'])}," - f"{str(intersection_spec['bbox']['longitude2'])}," - f"{str(intersection_spec['bbox']['latitude2'])})" - ) - if "intersection_name" in intersection_spec: - query += f", '{intersection_spec['intersection_name']}'" - if "origin_ip" in intersection_spec: - query += f", '{intersection_spec['origin_ip']}'" - - # Close the VALUES clause - query += ")" - pgquery.write_db(query) - - org_query = "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES" - for organization in intersection_spec["organizations"]: - org_query += ( - " (" - f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}'), " - f"(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" - ")," - ) - org_query = org_query[:-1] - pgquery.write_db(org_query) - if intersection_spec["rsus"]: - rsu_intersection_query = ( - "INSERT INTO public.rsu_intersection(rsu_id, intersection_id) VALUES" - ) - for rsu_ip in intersection_spec["rsus"]: - rsu_intersection_query += ( - " (" - f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{rsu_ip}'), " - f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}')" - ")," - ) - rsu_intersection_query = rsu_intersection_query[:-1] - pgquery.write_db(rsu_intersection_query) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "New Intersection successfully added"} - - -# REST endpoint resource class -class GeoPositionSchema(Schema): - latitude = fields.Decimal(required=True) - longitude = fields.Decimal(required=True) - - -class GeoPolygonSchema(Schema): - latitude1 = fields.Decimal(required=True) - longitude1 = fields.Decimal(required=True) - latitude2 = fields.Decimal(required=True) - longitude2 = fields.Decimal(required=True) - - -class AdminNewIntersectionSchema(Schema): - intersection_id = fields.Integer(required=True) - ref_pt = fields.Nested(GeoPositionSchema, required=True) - organizations = fields.List( - fields.String(), required=True, validate=validate.Length(min=1) - ) - bbox = fields.Nested(GeoPolygonSchema, required=False) - intersection_name = fields.String(required=False) - origin_ip = fields.IPv4(required=False) - rsus = fields.List(fields.IPv4(), required=True) - - -class AdminNewIntersection(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,POST", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.USER, - ) - def get(self, permission_result: PermissionResult): - logging.debug("AdminNewIntersection GET requested") - - return (get_allowed_selections(permission_result.user), 200, self.headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - ) - def post(self, permission_result: PermissionResult): - logging.debug("AdminNewIntersection POST requested") - # Check for main body values - schema = AdminNewIntersectionSchema() - errors = schema.validate(request.json) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=request.json, - keys_to_check=["organizations"], - ) - - return (add_intersection(request.json), 200, self.headers) diff --git a/services/api/src/admin_new_org.py b/services/api/src/admin_new_org.py index f123c814d..6f6070183 100644 --- a/services/api/src/admin_new_org.py +++ b/services/api/src/admin_new_org.py @@ -3,13 +3,52 @@ from marshmallow import Schema, fields import logging import common.pgquery as pgquery +import api_environment from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os -import admin_new_user from werkzeug.exceptions import InternalServerError, BadRequest from common.auth_tools import require_permission +def check_email(email): + email_special_characters = [ + "!!", + "##", + "$$", + "%%", + "&&", + "''", + "\\\\", + "**", + "++", + "--", + "//", + "==", + "??", + "^^", + "__", + "``", + "{{", + "||", + '"', + "(", + ")", + "}", + ",", + ":", + ";", + "<", + ">", + "[", + "]", + ] + if ( + any(check in email for check in email_special_characters) + or email.count("@") != 1 + ): + return False + return True + + def check_safe_input(org_spec): special_characters = "!\"#$%&'()*@-+,./:;<=>?[\\]^`{|}~" # Check all string based fields for special characters @@ -26,9 +65,7 @@ def add_organization(org_spec): ) if org_spec["email"]: - if org_spec["email"] != "" and not admin_new_user.check_email( - org_spec["email"] - ): + if org_spec["email"] != "" and not check_email(org_spec["email"]): raise BadRequest("Organization email is not valid") try: @@ -61,14 +98,14 @@ class AdminNewOrgSchema(Schema): class AdminNewOrg(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "POST", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/admin_new_rsu.py b/services/api/src/admin_new_rsu.py deleted file mode 100644 index cb6548721..000000000 --- a/services/api/src/admin_new_rsu.py +++ /dev/null @@ -1,235 +0,0 @@ -from typing import Any -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields, validate -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - EnvironWithOrg, - PermissionResult, - enforce_organization_restrictions, - get_qualified_org_list, - require_permission, -) - - -def get_allowed_selections(user: EnvironWithOrg): - allowed = {} - - primary_routes_query = ( - "SELECT DISTINCT primary_route FROM public.rsus ORDER BY primary_route ASC" - ) - rsu_models_query = ( - "SELECT manufacturers.name as manufacturer, rsu_models.name as model " - "FROM public.rsu_models " - "JOIN public.manufacturers ON rsu_models.manufacturer = manufacturers.manufacturer_id " - "ORDER BY manufacturer, model ASC" - ) - ssh_credential_nicknames_query = ( - "SELECT nickname FROM public.rsu_credentials ORDER BY nickname ASC" - ) - snmp_credential_nicknames_query = ( - "SELECT nickname FROM public.snmp_credentials ORDER BY nickname ASC" - ) - snmp_version_nicknames_query = ( - "SELECT nickname FROM public.snmp_protocols ORDER BY nickname ASC" - ) - - allowed["primary_routes"] = pgquery.query_and_return_list(primary_routes_query) - allowed["rsu_models"] = pgquery.query_and_return_list(rsu_models_query) - allowed["ssh_credential_groups"] = pgquery.query_and_return_list( - ssh_credential_nicknames_query - ) - allowed["snmp_credential_groups"] = pgquery.query_and_return_list( - snmp_credential_nicknames_query - ) - allowed["snmp_version_groups"] = pgquery.query_and_return_list( - snmp_version_nicknames_query - ) - - allowed["organizations"] = get_qualified_org_list( - user, ORG_ROLE_LITERAL.OPERATOR, include_super_user=True - ) - - return allowed - - -def check_safe_input(rsu_spec): - special_characters = "!\"#$%&'()*+,./:;<=>?@[\\]^`{|}~" - # Check all string based fields for special characters - if ( - any(c in special_characters for c in rsu_spec["primary_route"]) - or "--" in rsu_spec["primary_route"] - ): - return False - if ( - any(c in special_characters for c in rsu_spec["model"]) - or "--" in rsu_spec["model"] - ): - return False - if ( - any(c in special_characters for c in rsu_spec["serial_number"]) - or "--" in rsu_spec["serial_number"] - ): - return False - if ( - any(c in special_characters for c in rsu_spec["scms_id"]) - or "--" in rsu_spec["scms_id"] - ): - return False - if ( - any(c in special_characters for c in rsu_spec["ssh_credential_group"]) - or "--" in rsu_spec["ssh_credential_group"] - ): - return False - if ( - any(c in special_characters for c in rsu_spec["snmp_credential_group"]) - or "--" in rsu_spec["snmp_credential_group"] - ): - return False - return True - - -def add_rsu(rsu_spec: dict): - # Check for special characters for potential SQL injection - if not check_safe_input(rsu_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - # Parse model out of the "Manufacturer Model" string - space_index = rsu_spec["model"].find(" ") - manufacturer = rsu_spec["model"][:space_index] - model = rsu_spec["model"][(space_index + 1) :] - - # If RSU is a Commsignia or Kapsch, use the serial number for the SCMS ID - scms_id = rsu_spec["scms_id"] - if manufacturer == "Commsignia" or manufacturer == "Kapsch": - scms_id = rsu_spec["serial_number"] - else: - if scms_id == "": - raise BadRequest("SCMS ID must be specified") - - try: - query = ( - "INSERT INTO public.rsus(geography, milepost, ipv4_address, serial_number, primary_route, model, credential_id, snmp_credential_id, snmp_protocol_id, iss_scms_id) " - "VALUES (" - f"ST_GeomFromText('POINT({str(rsu_spec['geo_position']['longitude'])} {str(rsu_spec['geo_position']['latitude'])})'), " - f"{str(rsu_spec['milepost'])}, " - f"'{rsu_spec['ip']}', " - f"'{rsu_spec['serial_number']}', " - f"'{rsu_spec['primary_route']}', " - f"(SELECT rsu_model_id FROM public.rsu_models WHERE name = '{model}'), " - f"(SELECT credential_id FROM public.rsu_credentials WHERE nickname = '{rsu_spec['ssh_credential_group']}'), " - f"(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = '{rsu_spec['snmp_credential_group']}'), " - f"(SELECT snmp_protocol_id FROM public.snmp_protocols WHERE nickname = '{rsu_spec['snmp_version_group']}'), " - f"'{scms_id}'" - ")" - ) - pgquery.write_db(query) - - org_query = ( - "INSERT INTO public.rsu_organization(rsu_id, organization_id) VALUES" - ) - for organization in rsu_spec["organizations"]: - org_query += ( - " (" - f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{rsu_spec['ip']}'), " - f"(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" - ")," - ) - org_query = org_query[:-1] - pgquery.write_db(org_query) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "New RSU successfully added"} - - -# REST endpoint resource class -class GeoPositionSchema(Schema): - latitude = fields.Decimal(required=True) - longitude = fields.Decimal(required=True) - - -class AdminNewRsuSchema(Schema): - ip = fields.IPv4(required=True) - geo_position = fields.Nested(GeoPositionSchema, required=True) - milepost = fields.Decimal(required=True) - primary_route = fields.Str(required=True) - serial_number = fields.Str(required=True) - model = fields.Str(required=True) - scms_id = fields.Str(required=True) - ssh_credential_group = fields.Str(required=True) - snmp_credential_group = fields.Str(required=True) - snmp_version_group = fields.Str(required=True) - organizations = fields.List( - fields.String(), required=True, validate=validate.Length(min=1) - ) - - -class AdminNewRsu(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,POST", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - ) - def get(self, permission_result: PermissionResult): - logging.debug("AdminNewRsu GET requested") - return ( - get_allowed_selections(permission_result.user), - 200, - self.headers, - ) - - @require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - ) - def post(self, permission_result: PermissionResult): - logging.debug("AdminNewRsu POST requested") - # Check for main body values - if request.json is None: - raise BadRequest("No JSON body found") - body: dict[str, Any] = request.json - - schema = AdminNewRsuSchema() - errors = schema.validate(body) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=body, - keys_to_check=["organizations"], - ) - - return (add_rsu(body), 200, self.headers) diff --git a/services/api/src/admin_new_user.py b/services/api/src/admin_new_user.py deleted file mode 100644 index 565c2f42c..000000000 --- a/services/api/src/admin_new_user.py +++ /dev/null @@ -1,198 +0,0 @@ -from typing import Any -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields, validate -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import os -import time -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - EnvironWithOrg, - PermissionResult, - enforce_organization_restrictions, - get_qualified_org_list, - require_permission, -) - - -def get_allowed_selections(user: EnvironWithOrg): - allowed = {} - - allowed["organizations"] = get_qualified_org_list( - user, ORG_ROLE_LITERAL.ADMIN, include_super_user=True - ) - - roles_query = "SELECT name FROM public.roles ORDER BY name" - allowed["roles"] = pgquery.query_and_return_list(roles_query) - - return allowed - - -def check_email(email): - email_special_characters = [ - "!!", - "##", - "$$", - "%%", - "&&", - "''", - "\\\\", - "**", - "++", - "--", - "//", - "==", - "??", - "^^", - "__", - "``", - "{{", - "||", - '"', - "(", - ")", - "}", - ",", - ":", - ";", - "<", - ">", - "[", - "]", - ] - if ( - any(check in email for check in email_special_characters) - or email.count("@") != 1 - ): - return False - return True - - -def check_safe_input(user_spec): - special_characters = "!\"#$%&'()*@-+,./:;<=>?[\\]^`{|}~" - # Check all string based fields for special characters - if any(c in special_characters for c in user_spec["first_name"]): - return False - if any(c in special_characters for c in user_spec["last_name"]): - return False - for org in user_spec["organizations"]: - if any(c in special_characters for c in org["name"]): - return False - if any(c in special_characters for c in org["role"]): - return False - return True - - -def add_user(user_spec: dict): - # Check for special characters for potential SQL injection - if not check_email(user_spec["email"]): - raise BadRequest("Email is not valid") - if not check_safe_input(user_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - try: - current_timestamp = int(time.time() * 1000) - user_insert_query = ( - "INSERT INTO public.users(email, first_name, last_name, super_user, created_timestamp) " - f"VALUES ('{user_spec['email']}', '{user_spec['first_name']}', '{user_spec['last_name']}', '{'1' if user_spec['super_user'] else '0'}', {current_timestamp})" - ) - pgquery.write_db(user_insert_query) - - user_org_insert_query = "INSERT INTO public.user_organization(user_id, organization_id, role_id) VALUES" - for organization in user_spec["organizations"]: - user_org_insert_query += ( - " (" - f"(SELECT user_id FROM public.users WHERE email = '{user_spec['email']}'), " - f"(SELECT organization_id FROM public.organizations WHERE name = '{organization['name']}'), " - f"(SELECT role_id FROM public.roles WHERE name = '{organization['role']}')" - ")," - ) - user_org_insert_query = user_org_insert_query[:-1] - pgquery.write_db(user_org_insert_query) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "New user successfully added"} - - -# REST endpoint resource class -class UserOrganizationSchema(Schema): - name = fields.Str(required=True) - role = fields.Str(required=True) - - -class AdminNewUserSchema(Schema): - email = fields.Str(required=True) - first_name = fields.Str(required=True) - last_name = fields.Str(required=True) - super_user = fields.Bool(required=True) - organizations = fields.List( - fields.Nested(UserOrganizationSchema), - required=True, - validate=validate.Length(min=1), - ) - - -class AdminNewUser(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,POST", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - ) - def get(self, permission_result: PermissionResult): - logging.debug("AdminNewUser GET requested") - return (get_allowed_selections(permission_result.user), 200, self.headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.ADMIN, - ) - def post(self, permission_result: PermissionResult): - logging.debug("AdminNewUser POST requested") - # Check for main body values - if request.json is None: - raise BadRequest("No JSON body found") - body: dict[str, Any] = request.json - - schema = AdminNewUserSchema() - errors = schema.validate(body) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=request.json, - keys_to_check=["organizations"], - ) - - return (add_user(body), 200, self.headers) diff --git a/services/api/src/admin_org.py b/services/api/src/admin_org.py index 809cc4137..3a9b1fa7e 100644 --- a/services/api/src/admin_org.py +++ b/services/api/src/admin_org.py @@ -6,8 +6,8 @@ import logging import common.pgquery as pgquery from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import admin_new_user -import os +import api_environment +import admin_new_org from werkzeug.exceptions import InternalServerError, BadRequest, Conflict, Forbidden from common.auth_tools import ( ORG_ROLE_LITERAL, @@ -88,12 +88,14 @@ def get_org_data(org_name: str, is_admin_in_org: bool): rsu_query = ( "SELECT to_jsonb(row) " "FROM (" - "SELECT r.ipv4_address, r.primary_route, r.milepost " + "SELECT r.ipv4_address, r.primary_route, r.milepost, r.tim_deposit, r.snmp_monitoring " "FROM public.organizations AS org " "JOIN (" - "SELECT ro.organization_id, rsus.ipv4_address, rsus.primary_route, rsus.milepost " + "SELECT ro.organization_id, rsus.ipv4_address, rsus.primary_route, rsus.milepost, " + "COALESCE(opts.tim_deposit, FALSE) as tim_deposit, COALESCE(opts.snmp_monitoring, FALSE) as snmp_monitoring " "FROM public.rsu_organization ro " - "JOIN public.rsus ON ro.rsu_id = rsus.rsu_id" + "JOIN public.rsus ON ro.rsu_id = rsus.rsu_id " + "LEFT JOIN public.rsu_options opts ON rsus.rsu_id = opts.rsu_id" ") r ON r.organization_id = org.organization_id " "WHERE org.name = :org_name" ") as row" @@ -106,6 +108,8 @@ def get_org_data(org_name: str, is_admin_in_org: bool): rsu_obj["ip"] = str(row["ipv4_address"]) rsu_obj["primary_route"] = row["primary_route"] rsu_obj["milepost"] = row["milepost"] + rsu_obj["tim_deposit"] = row["tim_deposit"] + rsu_obj["snmp_monitoring"] = row["snmp_monitoring"] org_obj["org_rsus"].append(rsu_obj) # Get all Intersection members of the organization @@ -186,22 +190,20 @@ def check_safe_input(org_spec): if any(c in special_characters for c in org_spec["name"]): return False if org_spec["email"]: - if org_spec["email"] != "" and not admin_new_user.check_email( - org_spec["email"] - ): + if org_spec["email"] != "" and not admin_new_org.check_email(org_spec["email"]): return False for user in org_spec["users_to_add"]: - if not admin_new_user.check_email(user["email"]): + if not admin_new_org.check_email(user["email"]): return False if any(c in special_characters for c in user["role"]): return False for user in org_spec["users_to_modify"]: - if not admin_new_user.check_email(user["email"]): + if not admin_new_org.check_email(user["email"]): return False if any(c in special_characters for c in user["role"]): return False for user in org_spec["users_to_remove"]: - if not admin_new_user.check_email(user["email"]): + if not admin_new_org.check_email(user["email"]): return False if any(c in special_characters for c in user["role"]): return False @@ -212,7 +214,7 @@ def check_safe_input(org_spec): required_role=ORG_ROLE_LITERAL.ADMIN, resource_type=RESOURCE_TYPE.ORGANIZATION, ) -def modify_org_authorized(orig_name: str, org_spec: dict): +def modify_org_authorized(orig_name: str, org_spec: dict, is_bulk_update: bool = False): # Check for special characters for potential SQL injection if not check_safe_input(org_spec): raise BadRequest( @@ -234,6 +236,50 @@ def modify_org_authorized(orig_name: str, org_spec: dict): } pgquery.write_db(query, params=params) + # Handle bulk updates for tim_deposit and snmp_monitoring + if is_bulk_update and ( + "tim_deposit" in org_spec or "snmp_monitoring" in org_spec + ): + logging.info( + f"Bulk updating RSU options for all RSUs in organization {org_spec['name']}" + ) + + # Build the update query to handle both columns + # Only include columns that are actually being updated to avoid overwriting + # other column values with defaults + update_columns = [] + insert_columns = ["rsu_id"] + select_columns = ["ro.rsu_id"] + params_dict = {"name": org_spec["name"]} + + # Only add tim_deposit to the query if it's being updated + if "tim_deposit" in org_spec: + insert_columns.append("tim_deposit") + select_columns.append(":tim_deposit") + update_columns.append("tim_deposit = EXCLUDED.tim_deposit") + params_dict["tim_deposit"] = org_spec["tim_deposit"] + + # Only add snmp_monitoring to the query if it's being updated + if "snmp_monitoring" in org_spec: + insert_columns.append("snmp_monitoring") + select_columns.append(":snmp_monitoring") + update_columns.append("snmp_monitoring = EXCLUDED.snmp_monitoring") + params_dict["snmp_monitoring"] = org_spec["snmp_monitoring"] + + # This upsert will: + # - INSERT new rows with only the specified column(s) + # - UPDATE existing rows with only the specified column(s), preserving other values + bulk_update_query = ( + f"INSERT INTO public.rsu_options ({', '.join(insert_columns)}) " + f"SELECT {', '.join(select_columns)} " + "FROM public.rsu_organization ro " + "JOIN public.organizations org ON ro.organization_id = org.organization_id " + "WHERE org.name = :name " + f"ON CONFLICT (rsu_id) DO UPDATE SET {', '.join(update_columns)}" + ) + + pgquery.write_db(bulk_update_query, params=params_dict) + if len(org_spec["users_to_add"]) > 0: query_rows: list[tuple[str, dict]] = [] for index, user in enumerate(org_spec["users_to_add"]): @@ -384,7 +430,7 @@ def modify_org_authorized(orig_name: str, org_spec: dict): logging.error(f"SQL Exception encountered: {e}") raise InternalServerError("Encountered unknown issue executing query") from e - return {"message": "Organization successfully modified"} + return get_modify_org_data_authorized(org_spec["name"]) @require_permission( @@ -501,18 +547,20 @@ class AdminOrgPatchSchema(Schema): rsus_to_remove = fields.List(fields.IPv4(), required=True) intersections_to_add = fields.List(fields.Integer, required=True) intersections_to_remove = fields.List(fields.Integer, required=True) + tim_deposit = fields.Bool(required=False) + snmp_monitoring = fields.Bool(required=False) class AdminOrg(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "GET,PATCH,DELETE", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } @@ -544,7 +592,9 @@ def patch(self): logging.error(str(errors)) abort(400, str(errors)) return ( - modify_org_authorized(request.json["orig_name"], request.json), + modify_org_authorized( + request.json["orig_name"], request.json, is_bulk_update=False + ), 200, self.headers, ) @@ -561,3 +611,75 @@ def delete(self): org_name = urllib.request.unquote(request.args["org_name"]) return (delete_org_authorized(org_name), 200, self.headers) + + +class AdminOrgTimDeposit(Resource): + options_headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "PATCH", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + @require_permission(required_role=ORG_ROLE_LITERAL.ADMIN) + def patch(self): + logging.debug("AdminOrgTimDeposit PATCH requested") + + # Check for main body values + schema = AdminOrgPatchSchema() + errors = schema.validate(request.json) + if errors: + logging.error(str(errors)) + abort(400, str(errors)) + return ( + modify_org_authorized( + request.json["orig_name"], request.json, is_bulk_update=True + ), + 200, + self.headers, + ) + + +class AdminOrgSnmpMonitoring(Resource): + options_headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "PATCH", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + @require_permission(required_role=ORG_ROLE_LITERAL.ADMIN) + def patch(self): + logging.debug("AdminOrgSnmpMonitoring PATCH requested") + + # Check for main body values + schema = AdminOrgPatchSchema() + errors = schema.validate(request.json) + if errors: + logging.error(str(errors)) + abort(400, str(errors)) + return ( + modify_org_authorized( + request.json["orig_name"], request.json, is_bulk_update=True + ), + 200, + self.headers, + ) diff --git a/services/api/src/admin_rsu.py b/services/api/src/admin_rsu.py deleted file mode 100644 index 0d56358cb..000000000 --- a/services/api/src/admin_rsu.py +++ /dev/null @@ -1,353 +0,0 @@ -from typing import Any -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import admin_new_rsu -import os -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - RESOURCE_TYPE, - EnvironWithOrg, - PermissionResult, - enforce_organization_restrictions, - require_permission, - generate_sql_placeholders_for_list, -) - - -def get_rsu_data(rsu_ip: str, user: EnvironWithOrg, qualified_orgs: list[str]): - query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT ipv4_address, ST_X(geography::geometry) AS longitude, ST_Y(geography::geometry) AS latitude, " - "milepost, primary_route, serial_number, iss_scms_id, concat(man.name, ' ',rm.name) AS model, " - "rsu_cred.nickname AS ssh_credential, snmp_cred.nickname AS snmp_credential, snmp_ver.nickname AS snmp_version, org.name AS org_name " - "FROM public.rsus " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rsus.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - "JOIN public.rsu_credentials AS rsu_cred ON rsu_cred.credential_id = rsus.credential_id " - "JOIN public.snmp_credentials AS snmp_cred ON snmp_cred.snmp_credential_id = rsus.snmp_credential_id " - "JOIN public.snmp_protocols AS snmp_ver ON snmp_ver.snmp_protocol_id = rsus.snmp_protocol_id " - "JOIN public.rsu_organization AS ro ON ro.rsu_id = rsus.rsu_id " - "JOIN public.organizations AS org ON org.organization_id = ro.organization_id " - ) - - where_clauses = [] - params: dict[str, Any] = {} - if not user.user_info.super_user: - org_names_placeholder, _ = generate_sql_placeholders_for_list( - qualified_orgs, params_to_update=params - ) - where_clauses.append(f"org.name IN ({org_names_placeholder})") - if rsu_ip != "all": - where_clauses.append("ipv4_address = :rsu_ip") - params["rsu_ip"] = rsu_ip - if where_clauses: - query += "WHERE " + " AND ".join(where_clauses) - query += ") as row" - - data = pgquery.query_db(query, params=params) - - rsu_dict = {} - for row in data: - row = dict(row[0]) - if str(row["ipv4_address"]) not in rsu_dict: - rsu_dict[str(row["ipv4_address"])] = { - "ip": str(row["ipv4_address"]), - "geo_position": { - "latitude": row["latitude"], - "longitude": row["longitude"], - }, - "milepost": row["milepost"], - "primary_route": row["primary_route"], - "serial_number": row["serial_number"], - "scms_id": row["iss_scms_id"], - "model": row["model"], - "ssh_credential_group": row["ssh_credential"], - "snmp_credential_group": row["snmp_credential"], - "snmp_version_group": row["snmp_version"], - "organizations": [], - } - rsu_dict[str(row["ipv4_address"])]["organizations"].append(row["org_name"]) - - rsu_list = list(rsu_dict.values()) - # If list is empty and a single RSU was requested, return empty object - if len(rsu_list) == 0 and rsu_ip != "all": - return {} - # If list is not empty and a single RSU was requested, return the first index of the list - elif len(rsu_list) == 1 and rsu_ip != "all": - return rsu_list[0] - else: - return rsu_list - - -@require_permission( - required_role=ORG_ROLE_LITERAL.USER, - resource_type=RESOURCE_TYPE.RSU, -) -def get_modify_rsu_data_authorized(rsu_ip: str, permission_result: PermissionResult): - modify_rsu_obj = {} - modify_rsu_obj["rsu_data"] = get_rsu_data( - rsu_ip, permission_result.user, permission_result.qualified_orgs - ) - if rsu_ip != "all": - modify_rsu_obj["allowed_selections"] = admin_new_rsu.get_allowed_selections( - permission_result.user - ) - return modify_rsu_obj - - -@require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, resource_type=RESOURCE_TYPE.RSU -) -def modify_rsu_authorized( - permission_result: PermissionResult, orig_ip: str, rsu_spec: dict -): - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=rsu_spec, - keys_to_check=["organizations_to_add", "organizations_to_remove"], - ) - - # Check for special characters for potential SQL injection - if not admin_new_rsu.check_safe_input(rsu_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - # Parse model out of the "Manufacturer Model" string - space_index = rsu_spec["model"].find(" ") - model = rsu_spec["model"][(space_index + 1) :] - rsu_ip = rsu_spec["ip"] - - try: - # Modify the existing RSU data - query = ( - "UPDATE public.rsus SET " - "geography=ST_GeomFromText('POINT(' || :geo_position_longitude || ' ' || :geo_position_latitude || ')'), " - "milepost=:milepost, " - "ipv4_address=:rsu_ip, " - "serial_number=:serial_number, " - "primary_route=:primary_route, " - "model=(SELECT rsu_model_id FROM public.rsu_models WHERE name = :model), " - "credential_id=(SELECT credential_id FROM public.rsu_credentials WHERE nickname = :ssh_credential_group), " - "snmp_credential_id=(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = :snmp_credential_group), " - "snmp_protocol_id=(SELECT snmp_protocol_id FROM public.snmp_protocols WHERE nickname = :snmp_version_group), " - "iss_scms_id=:scms_id " - "WHERE ipv4_address=:orig_ip" - ) - params = { - "rsu_ip": rsu_ip, - "geo_position_longitude": rsu_spec["geo_position"]["longitude"], - "geo_position_latitude": rsu_spec["geo_position"]["latitude"], - "milepost": rsu_spec["milepost"], - "serial_number": rsu_spec["serial_number"], - "primary_route": rsu_spec["primary_route"], - "model": model, - "ssh_credential_group": rsu_spec["ssh_credential_group"], - "snmp_credential_group": rsu_spec["snmp_credential_group"], - "snmp_version_group": rsu_spec["snmp_version_group"], - "scms_id": rsu_spec["scms_id"], - "orig_ip": orig_ip, - } - pgquery.write_db(query, params=params) - - # Add the rsu-to-organization relationships for the organizations to add - if len(rsu_spec["organizations_to_add"]) > 0: - query_rows: list[tuple[str, dict]] = [] - for index, organization in enumerate(rsu_spec["organizations_to_add"]): - org_placeholder = f"org_name_{index}" - query_rows.append( - ( - "(" - "(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip), " - f"(SELECT organization_id FROM public.organizations WHERE name = :{org_placeholder})" - ")", - {org_placeholder: organization}, - ) - ) - - query_prefix = ( - "INSERT INTO public.rsu_organization(rsu_id, organization_id) VALUES " - ) - pgquery.write_db_batched( - query_prefix, query_rows, base_params={"rsu_ip": rsu_ip} - ) - - # Remove the rsu-to-organization relationships for the organizations to remove - if len(rsu_spec["organizations_to_remove"]) > 0: - params = {"rsu_ip": rsu_ip} - # Generate placeholders for each organization name - org_placeholders = [] - for idx, org in enumerate(rsu_spec["organizations_to_remove"]): - key = f"org_name_{idx}" - org_placeholders.append(f":{key}") - params[key] = org - - org_remove_query = ( - "DELETE FROM public.rsu_organization WHERE " - "rsu_id = (SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip) " - f"AND organization_id IN (SELECT organization_id FROM public.organizations WHERE name IN ({', '.join(org_placeholders)}))" - ) - pgquery.write_db(org_remove_query, params=params) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "RSU successfully modified"} - - -@require_permission( - required_role=ORG_ROLE_LITERAL.OPERATOR, - resource_type=RESOURCE_TYPE.RSU, -) -def delete_rsu_authorized(rsu_ip: str): - # Delete RSU to Organization relationships - org_remove_query = ( - "DELETE FROM public.rsu_organization WHERE " - "rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)" - ) - pgquery.write_db(org_remove_query, params={"rsu_ip": rsu_ip}) - - # Delete recorded RSU ping data - ping_remove_query = ( - "DELETE FROM public.ping WHERE " - "rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)" - ) - pgquery.write_db(ping_remove_query, params={"rsu_ip": rsu_ip}) - - # Delete recorded RSU SCMS health data - scms_remove_query = ( - "DELETE FROM public.scms_health WHERE " - "rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)" - ) - pgquery.write_db(scms_remove_query, params={"rsu_ip": rsu_ip}) - - # Delete snmp message forward config data - msg_config_remove_query = ( - "DELETE FROM public.snmp_msgfwd_config WHERE " - "rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)" - ) - pgquery.write_db(msg_config_remove_query, params={"rsu_ip": rsu_ip}) - - # Delete RSU data - rsu_remove_query = "DELETE FROM public.rsus WHERE ipv4_address = :rsu_ip" - pgquery.write_db(rsu_remove_query, params={"rsu_ip": rsu_ip}) - - return {"message": "RSU successfully deleted"} - - -# REST endpoint resource class -class AdminRsuGetAllSchema(Schema): - rsu_ip = fields.Str(required=True) - - -class AdminRsuGetDeleteSchema(Schema): - rsu_ip = fields.IPv4(required=True) - - -class GeoPositionSchema(Schema): - latitude = fields.Decimal(required=True) - longitude = fields.Decimal(required=True) - - -class AdminRsuPatchSchema(Schema): - orig_ip = fields.IPv4(required=True) - ip = fields.IPv4(required=True) - geo_position = fields.Nested(GeoPositionSchema, required=True) - milepost = fields.Decimal(required=True) - primary_route = fields.Str(required=True) - serial_number = fields.Str(required=True) - model = fields.Str(required=True) - scms_id = fields.Str(required=True) - ssh_credential_group = fields.Str(required=True) - snmp_credential_group = fields.Str(required=True) - snmp_version_group = fields.Str(required=True) - organizations_to_add = fields.List(fields.String(), required=True) - organizations_to_remove = fields.List(fields.String(), required=True) - - -class AdminRsu(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,PATCH,DELETE", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission(required_role=ORG_ROLE_LITERAL.USER) - def get(self): - logging.debug("AdminRsu GET requested") - - schema = AdminRsuGetAllSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - # If rsu_ip is "all", allow without checking for an IPv4 address - if request.args["rsu_ip"] != "all": - schema = AdminRsuGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - return ( - get_modify_rsu_data_authorized(request.args["rsu_ip"]), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.OPERATOR) - def patch(self): - logging.debug("AdminRsu PATCH requested") - - # Check for main body values - schema = AdminRsuPatchSchema() - errors = schema.validate(request.json) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - - return ( - modify_rsu_authorized( - orig_ip=request.json["orig_ip"], rsu_spec=request.json - ), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.OPERATOR) - def delete(self): - logging.debug("AdminRsu DELETE requested") - schema = AdminRsuGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - return (delete_rsu_authorized(request.args["rsu_ip"]), 200, self.headers) diff --git a/services/api/src/admin_user.py b/services/api/src/admin_user.py deleted file mode 100644 index 1875b43b7..000000000 --- a/services/api/src/admin_user.py +++ /dev/null @@ -1,348 +0,0 @@ -from typing import Any -from flask import request, abort -from flask_restful import Resource -from marshmallow import Schema, fields -import urllib.request -import logging -import common.pgquery as pgquery -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -import admin_new_user -import os -from werkzeug.exceptions import InternalServerError, BadRequest -from common.auth_tools import ( - ORG_ROLE_LITERAL, - RESOURCE_TYPE, - EnvironWithOrg, - PermissionResult, - enforce_organization_restrictions, - require_permission, - generate_sql_placeholders_for_list, -) - - -def get_user_data(user_email: str, user: EnvironWithOrg, qualified_orgs: list[str]): - query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT u.email, u.first_name, u.last_name, u.super_user, org.name, roles.name AS role " - "FROM public.users u " - "LEFT JOIN public.user_organization AS uo ON uo.user_id = u.user_id " - "LEFT JOIN public.organizations AS org ON org.organization_id = uo.organization_id " - "LEFT JOIN public.roles ON roles.role_id = uo.role_id " - ) - - where_clauses = [] - params: dict[str, Any] = {} - if not user.user_info.super_user: - org_names_placeholder, _ = generate_sql_placeholders_for_list( - qualified_orgs, params_to_update=params - ) - where_clauses.append(f"org.name IN ({org_names_placeholder})") - if user_email != "all": - where_clauses.append("u.email = :user_email") - params["user_email"] = user_email - if where_clauses: - query += "WHERE " + " AND ".join(where_clauses) - query += ") as row" - - data = pgquery.query_db(query, params=params) - - user_dict = {} - for row in data: - row = dict(row[0]) - if row["email"] not in user_dict: - user_dict[row["email"]] = { - "email": row["email"], - "first_name": row["first_name"], - "last_name": row["last_name"], - "super_user": True if row["super_user"] == "1" else False, - "organizations": [], - } - if row["name"] is not None and row["role"] is not None: - user_dict[row["email"]]["organizations"].append( - {"name": row["name"], "role": row["role"]} - ) - - user_list = list(user_dict.values()) - # If list is empty and a single user was requested, return empty object - if len(user_list) == 0 and user_email != "all": - return {} - # If list is not empty and a single user was requested, return the first index of the list - elif len(user_list) == 1 and user_email != "all": - return user_list[0] - else: - return user_list - - -@require_permission( - required_role=ORG_ROLE_LITERAL.ADMIN, - resource_type=RESOURCE_TYPE.USER, -) -def get_modify_user_data_authorized( - user_email: str, permission_result: PermissionResult -): - modify_user_obj = {} - modify_user_obj["user_data"] = get_user_data( - user_email, permission_result.user, permission_result.qualified_orgs - ) - if user_email != "all": - modify_user_obj["allowed_selections"] = admin_new_user.get_allowed_selections( - permission_result.user - ) - return modify_user_obj - - -def check_safe_input(user_spec): - special_characters = "!\"#$%&'()*@-+,./:;<=>?[\\]^`{|}~" - # Check all string based fields for special characters - if any(c in special_characters for c in user_spec["first_name"]): - return False - if any(c in special_characters for c in user_spec["last_name"]): - return False - for org in user_spec["organizations_to_add"]: - if any(c in special_characters for c in org["name"]): - return False - if any(c in special_characters for c in org["role"]): - return False - for org in user_spec["organizations_to_modify"]: - if any(c in special_characters for c in org["name"]): - return False - if any(c in special_characters for c in org["role"]): - return False - for org in user_spec["organizations_to_remove"]: - if any(c in special_characters for c in org["name"]): - return False - if any(c in special_characters for c in org["role"]): - return False - return True - - -def modify_user(orig_email: str, user_spec: dict): - # Check for special characters for potential SQL injection - if not admin_new_user.check_email( - user_spec["email"] - ) or not admin_new_user.check_email(orig_email): - raise BadRequest("Email is not valid") - if not check_safe_input(user_spec): - raise BadRequest( - "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - try: - # Modify the existing user data - query = ( - "UPDATE public.users SET " - "email=:email, " - "first_name=:first_name, " - "last_name=:last_name, " - "super_user=:super_user " - "WHERE email=:orig_email" - ) - params = { - "email": user_spec["email"], - "first_name": user_spec["first_name"], - "last_name": user_spec["last_name"], - "super_user": "1" if user_spec["super_user"] else "0", - "orig_email": orig_email, - } - pgquery.write_db(query, params=params) - - # Add the user-to-organization relationships - if len(user_spec["organizations_to_add"]) > 0: - query_rows: list[tuple[str, dict]] = [] - for index, organization in enumerate(user_spec["organizations_to_add"]): - org_name_placeholder = f"org_name_{index}" - org_role_placeholder = f"org_role_{index}" - query_rows.append( - ( - "(" - "(SELECT user_id FROM public.users WHERE email = :email), " - f"(SELECT organization_id FROM public.organizations WHERE name = :{org_name_placeholder}), " - f"(SELECT role_id FROM public.roles WHERE name = :{org_role_placeholder})" - ")", - { - org_name_placeholder: organization["name"], - org_role_placeholder: organization["role"], - }, - ) - ) - - query_prefix = "INSERT INTO public.user_organization(user_id, organization_id, role_id) VALUES " - pgquery.write_db_batched( - query_prefix, - query_rows, - base_params={"email": user_spec["email"]}, - ) - - # Modify the user-to-organization relationships - for organization in user_spec["organizations_to_modify"]: - org_modify_query = ( - "UPDATE public.user_organization " - "SET role_id = (SELECT role_id FROM public.roles WHERE name = :role) " - "WHERE user_id = (SELECT user_id FROM public.users WHERE email = :email) " - "AND organization_id = (SELECT organization_id FROM public.organizations WHERE name = :org_name)" - ) - params = { - "role": organization["role"], - "email": user_spec["email"], - "org_name": organization["name"], - } - pgquery.write_db(org_modify_query, params=params) - - # Remove the user-to-organization relationships - if len(user_spec["organizations_to_remove"]) > 0: - params = {"email": user_spec["email"]} - # Generate placeholders for each organization name - org_placeholders = [] - for idx, org in enumerate(user_spec["organizations_to_remove"]): - key = f"org_name_{idx}" - org_placeholders.append(f":{key}") - params[key] = org["name"] - - query = ( - "DELETE FROM public.user_organization WHERE " - "user_id = (SELECT user_id FROM public.users WHERE email = :email) " - f"AND organization_id IN (SELECT organization_id FROM public.organizations WHERE name IN ({', '.join(org_placeholders)}))" - ) - pgquery.write_db(query, params=params) - except IntegrityError as e: - if e.orig is None: - raise InternalServerError("Encountered unknown issue") from e - failed_value = e.orig.args[0]["D"] - failed_value = failed_value.replace("(", '"') - failed_value = failed_value.replace(")", '"') - failed_value = failed_value.replace("=", " = ") - logging.error(f"Exception encountered: {failed_value}") - raise InternalServerError(failed_value) from e - except SQLAlchemyError as e: - logging.error(f"SQL Exception encountered: {e}") - raise InternalServerError("Encountered unknown issue executing query") from e - - return {"message": "User successfully modified"} - - -@require_permission( - required_role=ORG_ROLE_LITERAL.ADMIN, - resource_type=RESOURCE_TYPE.USER, -) -def delete_user_authorized(user_email: str): - # Delete user-to-organization relationships - org_remove_query = ( - "DELETE FROM public.user_organization WHERE " - "user_id = (SELECT user_id FROM public.users WHERE email = :email)" - ) - params = {"email": user_email} - pgquery.write_db(org_remove_query, params=params) - - # Delete user data - user_remove_query = "DELETE FROM public.users WHERE email = :email" - params = {"email": user_email} - pgquery.write_db(user_remove_query, params=params) - - return {"message": "User successfully deleted"} - - -# REST endpoint resource class -class AdminUserGetDeleteSchema(Schema): - user_email = fields.Str(required=True) - - -class UserOrganizationSchema(Schema): - name = fields.Str(required=True) - role = fields.Str(required=True) - - -class AdminUserPatchSchema(Schema): - orig_email = fields.Str(required=True) - email = fields.Str(required=True) - first_name = fields.Str(required=True) - last_name = fields.Str(required=True) - super_user = fields.Bool(required=True) - organizations_to_add = fields.List( - fields.Nested(UserOrganizationSchema), required=True - ) - organizations_to_modify = fields.List( - fields.Nested(UserOrganizationSchema), required=True - ) - organizations_to_remove = fields.List( - fields.Nested(UserOrganizationSchema), required=True - ) - - -class AdminUser(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET,PATCH,DELETE", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission(required_role=ORG_ROLE_LITERAL.ADMIN) - def get(self): - logging.debug("AdminUser GET requested") - - schema = AdminUserGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - user_email = urllib.request.unquote(request.args["user_email"]) - return ( - get_modify_user_data_authorized(user_email), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.ADMIN) - def patch(self, permission_result: PermissionResult): - logging.debug("AdminUser PATCH requested") - - # Check for main body values - if request.json is None: - raise BadRequest("No JSON body found") - body: dict[str, Any] = request.json - - schema = AdminUserPatchSchema() - errors = schema.validate(body) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - - enforce_organization_restrictions( - user=permission_result.user, - qualified_orgs=permission_result.qualified_orgs, - spec=body, - keys_to_check=[ - "organizations_to_add", - "organizations_to_modify", - "organizations_to_remove", - ], - ) - - return ( - modify_user(body["orig_email"], body), - 200, - self.headers, - ) - - @require_permission(required_role=ORG_ROLE_LITERAL.ADMIN) - def delete(self): - logging.debug("AdminUser DELETE requested") - schema = AdminUserGetDeleteSchema() - errors = schema.validate(request.args) - if errors: - logging.error(errors) - abort(400, errors) - - user_email = urllib.request.unquote(request.args["user_email"]) - return (delete_user_authorized(user_email), 200, self.headers) diff --git a/services/api/src/api_environment.py b/services/api/src/api_environment.py new file mode 100644 index 000000000..56c38cb29 --- /dev/null +++ b/services/api/src/api_environment.py @@ -0,0 +1,46 @@ +from common.common_environment import get_env_var + + +APPLICATION_PORT = int(get_env_var("FLASK_RUN_PORT", "5000")) + +ENABLE_RSU_FEATURES = get_env_var("ENABLE_RSU_FEATURES", "true").lower() != "false" +ENABLE_INTERSECTION_FEATURES = ( + get_env_var("ENABLE_INTERSECTION_FEATURES", "true").lower() != "false" +) +ENABLE_WZDX_FEATURES = get_env_var("ENABLE_WZDX_FEATURES", "true").lower() != "false" + +KEYCLOAK_ENDPOINT = get_env_var("KEYCLOAK_ENDPOINT", error=True) +KEYCLOAK_REALM = get_env_var("KEYCLOAK_REALM", error=True) +KEYCLOAK_API_CLIENT_ID = get_env_var("KEYCLOAK_API_CLIENT_ID", error=True) +KEYCLOAK_API_CLIENT_SECRET_KEY = get_env_var( + "KEYCLOAK_API_CLIENT_SECRET_KEY", error=True +) + +CORS_DOMAIN = get_env_var("CORS_DOMAIN", "*") + +MONGO_DB_URI = get_env_var("MONGO_DB_URI", "mongodb://localhost:27017/", warn=True) +MONGO_DB_NAME = get_env_var("MONGO_DB_NAME", "CV", warn=True) +MONGO_SSM_COLLECTION_NAME = get_env_var("MONGO_SSM_COLLECTION_NAME") +MONGO_SRM_COLLECTION_NAME = get_env_var("MONGO_SRM_COLLECTION_NAME") +MONGO_PROCESSED_BSM_COLLECTION_NAME = get_env_var( + "MONGO_PROCESSED_BSM_COLLECTION_NAME", "ProcessedBsm", warn=False +) +MONGO_PROCESSED_PSM_COLLECTION_NAME = get_env_var( + "MONGO_PROCESSED_PSM_COLLECTION_NAME", "ProcessedPsm", warn=False +) +MAX_GEO_QUERY_RECORDS = int(get_env_var("MAX_GEO_QUERY_RECORDS", "10000", warn=False)) + +ENVIRONMENT_NAME = get_env_var("ENVIRONMENT_NAME") +LOGS_LINK = get_env_var("LOGS_LINK") + +WZDX_ENDPOINT = get_env_var("WZDX_ENDPOINT", error=ENABLE_WZDX_FEATURES) +WZDX_API_KEY = get_env_var("WZDX_API_KEY", error=ENABLE_WZDX_FEATURES) + +FIRMWARE_MANAGER_ENDPOINT = get_env_var("FIRMWARE_MANAGER_ENDPOINT", warn=False) + +ENABLE_ERROR_EMAILS = get_env_var("ENABLE_ERROR_EMAILS", "false").lower() != "false" +IAPI_ENDPOINT = get_env_var("IAPI_ENDPOINT", error=ENABLE_ERROR_EMAILS) +KC_SA_CLIENT_ID = get_env_var( + "KC_SA_CLIENT_ID", "sa_cvmanager_python_api", error=ENABLE_ERROR_EMAILS +) +KC_SA_CLIENT_SECRET = get_env_var("KC_SA_CLIENT_SECRET", error=ENABLE_ERROR_EMAILS) diff --git a/services/api/src/contact_support.py b/services/api/src/contact_support.py deleted file mode 100644 index decb785df..000000000 --- a/services/api/src/contact_support.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -import os -from flask import abort, request -from flask_restful import Resource -from marshmallow import Schema -from marshmallow import fields - -from common.emailSender import EmailSender -from common.email_util import get_email_list - - -class ContactSupportSchema(Schema): - email = fields.Str(required=True) - subject = fields.Str(required=True) - message = fields.Str(required=True) - - -class ContactSupportResource(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "POST,OPTIONS", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "POST,OPTIONS", - "Content-Type": "application/json", - } - - def __init__(self): - self.CSM_EMAIL_TO_SEND_FROM = os.environ.get("CSM_EMAIL_TO_SEND_FROM") - self.CSM_TARGET_SMTP_SERVER_ADDRESS = os.environ.get( - "CSM_TARGET_SMTP_SERVER_ADDRESS" - ) - self.CSM_TARGET_SMTP_SERVER_PORT = int( - os.environ.get("CSM_TARGET_SMTP_SERVER_PORT") - ) - self.CSM_TLS_ENABLED = os.environ.get("CSM_TLS_ENABLED", "true") - self.CSM_AUTH_ENABLED = os.environ.get("CSM_AUTH_ENABLED", "true") - self.CSM_EMAIL_APP_USERNAME = os.environ.get("CSM_EMAIL_APP_USERNAME") - self.CSM_EMAIL_APP_PASSWORD = os.environ.get("CSM_EMAIL_APP_PASSWORD") - - if not self.CSM_EMAIL_TO_SEND_FROM: - logging.error("CSM_EMAIL_TO_SEND_FROM environment variable not set") - abort(500) - if not self.CSM_TARGET_SMTP_SERVER_ADDRESS: - logging.error("CSM_TARGET_SMTP_SERVER_ADDRESS environment variable not set") - abort(500) - if not self.CSM_TARGET_SMTP_SERVER_PORT: - logging.error("CSM_TARGET_SMTP_SERVER_PORT environment variable not set") - abort(500) - - if self.CSM_AUTH_ENABLED == "true": - if not self.CSM_EMAIL_APP_USERNAME: - logging.error("CSM_EMAIL_APP_USERNAME environment variable not set") - abort(500) - if not self.CSM_EMAIL_APP_PASSWORD: - logging.error("CSM_EMAIL_APP_PASSWORD environment variable not set") - abort(500) - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - # TODO: Enforce authentication, when automatic user registration is implemented - def post(self): - logging.debug("ContactSupport POST requested") - # Check for main body values - if not request.json: - logging.error("No JSON body provided") - abort(400) - - self.validate_input(request.json) - - try: - replyEmail = request.json["email"] - subject = request.json["subject"] - message = request.json["message"] - - email_addresses = get_email_list("Support Requests") - for email_address in email_addresses: - emailSender = EmailSender( - self.CSM_TARGET_SMTP_SERVER_ADDRESS, - self.CSM_TARGET_SMTP_SERVER_PORT, - ) - emailSender.send( - self.CSM_EMAIL_TO_SEND_FROM, - email_address, - subject, - message, - replyEmail, - self.CSM_EMAIL_APP_USERNAME, - self.CSM_EMAIL_APP_PASSWORD, - False, - self.CSM_TLS_ENABLED, - self.CSM_AUTH_ENABLED, - ) - except Exception as e: - logging.error(f"Exception encountered: {e}") - abort(500) - return ("", 200, self.headers) - - def validate_input(self, input): - try: - schema = ContactSupportSchema() - schema.load(input) - except Exception as e: - logging.error(f"Exception encountered: {e}") - abort(400) diff --git a/services/api/src/healthcheck.py b/services/api/src/healthcheck.py index ef50a0ab8..89bc1d6fa 100644 --- a/services/api/src/healthcheck.py +++ b/services/api/src/healthcheck.py @@ -1,4 +1,4 @@ -import os +import api_environment # REST endpoint resource class from flask_restful import Resource @@ -6,14 +6,14 @@ class HealthCheck(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/iss_scms_status.py b/services/api/src/iss_scms_status.py deleted file mode 100644 index 163e9245a..000000000 --- a/services/api/src/iss_scms_status.py +++ /dev/null @@ -1,76 +0,0 @@ -from flask_restful import Resource -import logging -import common.pgquery as pgquery -import common.util as util -import os - -from common.auth_tools import ( - ORG_ROLE_LITERAL, - PermissionResult, - require_permission, -) - - -def get_iss_scms_status(organization: str) -> dict: - # Execute the query and fetch all results - query = ( - "SELECT jsonb_build_object('ip', rd.ipv4_address, 'health', scms_health_data.health, 'expiration', scms_health_data.expiration) " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "LEFT JOIN (" - "SELECT a.rsu_id, a.health, a.timestamp, a.expiration " - "FROM (" - "SELECT sh.rsu_id, sh.health, sh.timestamp, sh.expiration, ROW_NUMBER() OVER (PARTITION BY sh.rsu_id order by sh.timestamp DESC) AS row_id " - "FROM public.scms_health AS sh" - ") AS a " - "WHERE a.row_id <= 1 ORDER BY rsu_id" - ") AS scms_health_data ON rd.rsu_id = scms_health_data.rsu_id " - "WHERE ron_v.name = :org_name " - "ORDER BY rd.ipv4_address" - ) - params = {"org_name": organization} - logging.debug(f'Executing query "{query};"') - data = pgquery.query_db(query, params=params) - - logging.info("Parsing results...") - result = {} - for row in data: - row = dict(row[0]) - result[row["ip"]] = ( - { - "health": row["health"], - "expiration": util.format_date_denver(row["expiration"]), - } - if row["health"] - else None - ) - return result - - -class IssScmsStatus(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.USER, - ) - def get(self, permission_result: PermissionResult): - logging.debug("IssScmsStatus GET requested") - return ( - get_iss_scms_status(permission_result.user.organization), - 200, - self.headers, - ) diff --git a/services/api/src/main.py b/services/api/src/main.py index 383f53a1b..678629885 100644 --- a/services/api/src/main.py +++ b/services/api/src/main.py @@ -1,51 +1,35 @@ from flask import Flask from flask_restful import Api -import os +import api_environment import logging # Custom script imports from middleware import Middleware from admin_email_notification import AdminNotification from admin_new_email_notification import AdminNewNotification -from userauth import UserAuth from healthcheck import HealthCheck -from rsuinfo import RsuInfo from rsu_querycounts import RsuQueryCounts from rsu_querymsgfwd import RsuQueryMsgFwd from rsu_online_status import RsuOnlineStatus from rsu_commands import RsuCommandRequest +from rsu_snmp_fwd_fetch import RsuSnmpFwdFetch from rsu_geo_query import RsuGeoQuery from wzdx_feed import WzdxFeed from rsu_geo_msg_query import RsuGeoData -from moove_ai_query import MooveAiData -from iss_scms_status import IssScmsStatus from rsu_ssm_srm import RsuSsmSrmData -from admin_new_rsu import AdminNewRsu -from admin_rsu import AdminRsu -from admin_new_intersection import AdminNewIntersection -from admin_intersection import AdminIntersection -from admin_new_user import AdminNewUser -from admin_user import AdminUser from admin_new_org import AdminNewOrg -from admin_org import AdminOrg -from contact_support import ContactSupportResource -from rsu_error_summary import RSUErrorSummaryResource +from admin_org import AdminOrg, AdminOrgTimDeposit, AdminOrgSnmpMonitoring import smtp_error_handler +from common import common_environment -log_level = os.environ.get("LOGGING_LEVEL", "INFO") -logging.basicConfig(format="%(levelname)s:%(message)s", level=log_level) +logging.info( + "CVManager API running with LOGGING_LEVEL: " + str(common_environment.LOGGING_LEVEL) +) app = Flask(__name__) -# Feature flag environment variables -ENABLE_RSU_FEATURES = os.environ.get("ENABLE_RSU_FEATURES", "true") != "false" -ENABLE_INTERSECTION_FEATURES = ( - os.environ.get("ENABLE_INTERSECTION_FEATURES", "true") != "false" -) -ENABLE_WZDX_FEATURES = os.environ.get("ENABLE_WZDX_FEATURES", "true") != "false" -ENABLE_MOOVE_AI_FEATURES = os.environ.get("ENABLE_MOOVE_AI_FEATURES", "true") != "false" - -smtp_error_handler.configure_error_emails(app) +if api_environment.ENABLE_ERROR_EMAILS: + smtp_error_handler.configure_error_emails(app) app.wsgi_app = Middleware(app.wsgi_app) @@ -53,42 +37,31 @@ @app.after_request def apply_cors_header(response): # Add CORS header to all responses to prevent webapp parsing errors. Webapps have trouble handling responses that do not have the Access-Control-Allow-Origin header set. - response.headers["Access-Control-Allow-Origin"] = os.environ["CORS_DOMAIN"] + response.headers["Access-Control-Allow-Origin"] = api_environment.CORS_DOMAIN return response api = Api(app) api.add_resource(HealthCheck, "/") -api.add_resource(UserAuth, "/user-auth") -api.add_resource(AdminNewUser, "/admin-new-user") -api.add_resource(AdminUser, "/admin-user") api.add_resource(AdminNewOrg, "/admin-new-org") api.add_resource(AdminOrg, "/admin-org") +api.add_resource(AdminOrgTimDeposit, "/admin-org-tim-deposit") +api.add_resource(AdminOrgSnmpMonitoring, "/admin-org-snmp-monitoring") api.add_resource(AdminNotification, "/admin-notification") api.add_resource(AdminNewNotification, "/admin-new-notification") -api.add_resource(ContactSupportResource, "/contact-support") -if ENABLE_RSU_FEATURES: - api.add_resource(RsuInfo, "/rsuinfo") +if api_environment.ENABLE_RSU_FEATURES: api.add_resource(RsuOnlineStatus, "/rsu-online-status") api.add_resource(RsuQueryCounts, "/rsucounts") api.add_resource(RsuQueryMsgFwd, "/rsu-msgfwd-query") + api.add_resource(RsuSnmpFwdFetch, "/rsu-msgfwd-fetch") api.add_resource(RsuCommandRequest, "/rsu-command") api.add_resource(RsuGeoQuery, "/rsu-config-geo-query") api.add_resource(RsuGeoData, "/rsu-geo-msg-data") - api.add_resource(IssScmsStatus, "/iss-scms-status") api.add_resource(RsuSsmSrmData, "/rsu-ssm-srm-data") - api.add_resource(AdminNewRsu, "/admin-new-rsu") - api.add_resource(AdminRsu, "/admin-rsu") - api.add_resource(RSUErrorSummaryResource, "/rsu-error-summary") -if ENABLE_WZDX_FEATURES: +if api_environment.ENABLE_WZDX_FEATURES: api.add_resource(WzdxFeed, "/wzdx-feed") -if ENABLE_INTERSECTION_FEATURES: - api.add_resource(AdminNewIntersection, "/admin-new-intersection") - api.add_resource(AdminIntersection, "/admin-intersection") -if ENABLE_MOOVE_AI_FEATURES: - api.add_resource(MooveAiData, "/moove-ai-data") if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + app.run(host="0.0.0.0", port=api_environment.APPLICATION_PORT) diff --git a/services/api/src/middleware.py b/services/api/src/middleware.py index 0ca717659..26c765aca 100644 --- a/services/api/src/middleware.py +++ b/services/api/src/middleware.py @@ -4,7 +4,7 @@ from werkzeug.wrappers import Request from keycloak import KeycloakOpenID import logging -import os +import api_environment import jwt from werkzeug.exceptions import HTTPException, Forbidden, Unauthorized, NotImplemented @@ -22,27 +22,15 @@ class FEATURE_KEYS_LITERAL(Enum): RSU = "rsu" INTERSECTION = "intersection" WZDX = "wzdx" - MOOVE_AI = "moove_ai" - - -# Feature flag environment variables -ENABLE_RSU_FEATURES = os.getenv("ENABLE_RSU_FEATURES", "true").lower() != "false" -ENABLE_INTERSECTION_FEATURES = ( - os.getenv("ENABLE_INTERSECTION_FEATURES", "true").lower() != "false" -) -ENABLE_WZDX_FEATURES = os.getenv("ENABLE_WZDX_FEATURES", "true").lower() != "false" -ENABLE_MOOVE_AI_FEATURES = ( - os.getenv("ENABLE_MOOVE_AI_FEATURES", "true").lower() != "false" -) def get_user_role(token) -> UserInfo | None: # TODO: Consider using pythjon-jose or PyJWT to locally validate the token, instead of calling the Keycloak server keycloak_openid = KeycloakOpenID( - server_url=os.getenv("KEYCLOAK_ENDPOINT"), - realm_name=os.getenv("KEYCLOAK_REALM"), - client_id=os.getenv("KEYCLOAK_API_CLIENT_ID"), - client_secret_key=os.getenv("KEYCLOAK_API_CLIENT_SECRET_KEY"), + server_url=api_environment.KEYCLOAK_ENDPOINT, + realm_name=api_environment.KEYCLOAK_REALM, + client_id=api_environment.KEYCLOAK_API_CLIENT_ID, + client_secret_key=api_environment.KEYCLOAK_API_CLIENT_SECRET_KEY, ) introspect = keycloak_openid.introspect(token) data = None @@ -64,30 +52,22 @@ def get_user_role(token) -> UserInfo | None: organization_required = { - "/user-auth": False, - "/rsuinfo": True, "/rsu-online-status": True, "/rsucounts": True, "/rsu-msgfwd-query": True, "/rsu-command": True, - "/iss-scms-status": True, "/wzdx-feed": False, "/rsu-geo-msg-data": False, "/rsu-ssm-srm-data": False, "/admin-new-rsu": False, - "/admin-rsu": False, - "/admin-new-intersection": False, - "/admin-intersection": False, - "/admin-new-user": False, - "/admin-user": False, "/admin-new-org": False, "/admin-org": False, + "/admin-org-tim-deposit": False, + "/admin-org-snmp-monitoring": False, "/rsu-config-geo-query": True, "/rsu-geo-query": True, - "/moove-ai-data": False, "/admin-new-notification": False, "/admin-notification": False, - "/rsu-error-summary": False, } # Tag endpoints with the feature they require. The tagged endpoints will automatically be disabled if the feature is disabled @@ -96,31 +76,23 @@ def get_user_role(token) -> UserInfo | None: # Dictionary: Method specific feature required (e.g. {"GET": "rsu", "POST": "intersection"}) feature_tags: dict[str, FEATURE_KEYS_LITERAL | None] = { "/": None, - "/user-auth": None, - "/rsuinfo": FEATURE_KEYS_LITERAL.RSU, "/rsu-online-status": FEATURE_KEYS_LITERAL.RSU, "/rsucounts": FEATURE_KEYS_LITERAL.RSU, "/rsu-msgfwd-query": FEATURE_KEYS_LITERAL.RSU, "/rsu-command": FEATURE_KEYS_LITERAL.RSU, "/rsu-map-info": FEATURE_KEYS_LITERAL.RSU, - "/iss-scms-status": FEATURE_KEYS_LITERAL.RSU, "/wzdx-feed": FEATURE_KEYS_LITERAL.WZDX, "/rsu-geo-msg-data": FEATURE_KEYS_LITERAL.RSU, "/rsu-ssm-srm-data": FEATURE_KEYS_LITERAL.RSU, "/admin-new-rsu": FEATURE_KEYS_LITERAL.RSU, - "/admin-rsu": FEATURE_KEYS_LITERAL.RSU, - "/admin-new-intersection": FEATURE_KEYS_LITERAL.INTERSECTION, - "/admin-intersection": FEATURE_KEYS_LITERAL.INTERSECTION, - "/admin-new-user": None, - "/admin-user": None, "/admin-new-org": None, "/admin-org": None, + "/admin-org-tim-deposit": None, + "/admin-org-snmp-monitoring": None, "/rsu-config-geo-query": FEATURE_KEYS_LITERAL.RSU, "/rsu-geo-query": FEATURE_KEYS_LITERAL.RSU, - "/moove-ai-data": FEATURE_KEYS_LITERAL.MOOVE_AI, "/admin-new-notification": None, "/admin-notification": None, - "/rsu-error-summary": FEATURE_KEYS_LITERAL.RSU, } @@ -129,7 +101,7 @@ def check_auth_exempt(method, path): if method == "OPTIONS": return True - exempt_paths = ["/", "/contact-support"] + exempt_paths = ["/"] if path in exempt_paths: return True @@ -145,13 +117,14 @@ def is_tag_disabled(tag: FEATURE_KEYS_LITERAL | None) -> bool: Returns: bool: True if the feature should be disabled, False otherwise """ - if not ENABLE_RSU_FEATURES and tag == FEATURE_KEYS_LITERAL.RSU: - return True - elif not ENABLE_INTERSECTION_FEATURES and tag == FEATURE_KEYS_LITERAL.INTERSECTION: + if not api_environment.ENABLE_RSU_FEATURES and tag == FEATURE_KEYS_LITERAL.RSU: return True - elif not ENABLE_WZDX_FEATURES and tag == FEATURE_KEYS_LITERAL.WZDX: + elif ( + not api_environment.ENABLE_INTERSECTION_FEATURES + and tag == FEATURE_KEYS_LITERAL.INTERSECTION + ): return True - elif not ENABLE_MOOVE_AI_FEATURES and tag == FEATURE_KEYS_LITERAL.MOOVE_AI: + elif not api_environment.ENABLE_WZDX_FEATURES and tag == FEATURE_KEYS_LITERAL.WZDX: return True return False @@ -185,7 +158,7 @@ class Middleware: def __init__(self, app): self.app = app self.default_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } @@ -240,7 +213,7 @@ def __call__(self, environ, start_response): response_body, status=e.code, content_type="application/json", - headers={"Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"]}, + headers={"Access-Control-Allow-Origin": api_environment.CORS_DOMAIN}, ) return response(environ, start_response) @@ -255,6 +228,6 @@ def __call__(self, environ, start_response): response_body, status=500, content_type="application/json", - headers={"Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"]}, + headers={"Access-Control-Allow-Origin": api_environment.CORS_DOMAIN}, ) return response(environ, start_response) diff --git a/services/api/src/moove_ai_query.py b/services/api/src/moove_ai_query.py deleted file mode 100644 index 0b754a622..000000000 --- a/services/api/src/moove_ai_query.py +++ /dev/null @@ -1,106 +0,0 @@ -from flask import abort, request -from flask_restful import Resource -from marshmallow import Schema, fields -from google.cloud import bigquery -import os -import logging -import pandas as pd -from shapely import wkt -from werkzeug.exceptions import InternalServerError - -from common.auth_tools import ORG_ROLE_LITERAL, require_permission - - -def query_moove_ai(pointList): - pointListWkt = ( - "POLYGON((" - + ", ".join([f"{point[0]} {point[1]}" for point in pointList]) - + "))" - ) - - client = bigquery.Client(location="US", project=os.getenv("GCP_PROJECT_ID")) - segment_agg_stats_table = os.getenv("MOOVE_AI_SEGMENT_AGG_STATS_TABLE") - segment_event_stats_table = os.getenv("MOOVE_AI_SEGMENT_EVENT_STATS_TABLE") - query = f""" - SELECT - sas.segment_id, - sev.total_hard_brake_count, - sev.shape_geom - FROM - `{segment_agg_stats_table}` AS sas - INNER JOIN - `{segment_event_stats_table}` AS sev ON (sas.segment_id = sev.segment_id) - WHERE ST_WITHIN( - sev.shape_geom, - ST_GEOGFROMTEXT('{pointListWkt}') - ) OR ST_INTERSECTS( - sev.shape_geom, - ST_GEOGFROMTEXT('{pointListWkt}')) - ORDER BY segment_id DESC - """ - - try: - logging.debug(f"Starting query: {query}") - df = client.query(query).to_dataframe() - df = df.where(pd.notnull(df), None) - - segment_data = [] - for index, row in df.iterrows(): - shape_geom = wkt.loads(row["shape_geom"]) - segment_geojson = { - "type": "Feature", - "geometry": { - "type": "LineString", - "coordinates": list(shape_geom.coords), - }, - "properties": { - "segment_id": row["segment_id"], - "total_hard_brake_count": row["total_hard_brake_count"], - }, - } - - segment_data.append(segment_geojson) - - logging.info(f"Total Moove AI segments processed: {len(segment_data)}") - return segment_data - except Exception as e: - logging.error(f"Moove AI query failed: {e}") - raise InternalServerError( - f"Encountered unknown issue querying Moove AI data: {e}" - ) from e - - -# REST endpoint resource class -class MooveAiDataSchema(Schema): - geometry = fields.List(fields.List(fields.Float)) - - -class MooveAiData(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission(required_role=ORG_ROLE_LITERAL.USER) - def post(self): - logging.debug("MooveAiData POST requested") - - # Check for main body values - schema = MooveAiDataSchema() - errors = schema.validate(request.json) - if errors: - logging.error(str(errors)) - abort(400, str(errors)) - - return (query_moove_ai(request.json["geometry"]), 200, self.headers) diff --git a/services/api/src/rsu_commands.py b/services/api/src/rsu_commands.py index 3767aa1b7..af1b52318 100644 --- a/services/api/src/rsu_commands.py +++ b/services/api/src/rsu_commands.py @@ -14,7 +14,7 @@ ) import ssh_commands import rsu_snmpset -import os +import api_environment # Dict of functions command_data = { @@ -176,13 +176,13 @@ class RsuCommandRequestSchema(Schema): class RsuCommandRequest(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", "Access-Control-Allow-Methods": "GET,POST", "Access-Control-Max-Age": "3600", } - headers = {"Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"]} + headers = {"Access-Control-Allow-Origin": api_environment.CORS_DOMAIN} def options(self): # CORS support diff --git a/services/api/src/rsu_error_summary.py b/services/api/src/rsu_error_summary.py deleted file mode 100644 index 063eae6ad..000000000 --- a/services/api/src/rsu_error_summary.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging -import os -from flask import abort, request -from flask_restful import Resource -from marshmallow import Schema -from marshmallow import fields - -from common.emailSender import EmailSender -from common.auth_tools import ( - ORG_ROLE_LITERAL, - require_permission, -) - - -class RSUErrorSummarySchema(Schema): - emails = fields.Str(required=True) - subject = fields.Str(required=True) - message = fields.Str(required=True) - - -class RSUErrorSummaryResource(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "POST,OPTIONS", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "POST,OPTIONS", - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission(required_role=ORG_ROLE_LITERAL.OPERATOR) - def post(self): - logging.debug("RSUErrorSummary POST requested") - - # Check for main body values - if not request.json: - logging.error("No JSON body provided") - abort(400) - - self.validate_input(request.json) - try: - email_addresses = request.json["emails"].split(",") - subject = request.json["subject"] - message = request.json["message"] - - for email_address in email_addresses: - logging.info(f"Sending email to {email_address}") - emailSender = EmailSender( - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"], - 587, - ) - emailSender.send( - sender=os.environ["CSM_EMAIL_TO_SEND_FROM"], - recipient=email_address, - subject=subject, - message=message, - replyEmail="", - username=os.environ["CSM_EMAIL_APP_USERNAME"], - password=os.environ["CSM_EMAIL_APP_PASSWORD"], - pretty=True, - ) - except Exception as e: - logging.error(f"Exception encountered: {e}") - abort(500) - return ("", 200, self.headers) - - def validate_input(self, input): - try: - schema = RSUErrorSummarySchema() - schema.load(input) - except Exception as e: - logging.error(f"Exception encountered: {e}") - abort(400) diff --git a/services/api/src/rsu_geo_msg_query.py b/services/api/src/rsu_geo_msg_query.py index 3ac472497..9321ae368 100644 --- a/services/api/src/rsu_geo_msg_query.py +++ b/services/api/src/rsu_geo_msg_query.py @@ -2,12 +2,11 @@ from flask_restful import Resource from marshmallow import Schema, fields import common.util as util -import os +import api_environment import logging from datetime import datetime from pymongo import MongoClient, ASCENDING, GEOSPHERE from werkzeug.exceptions import InternalServerError - from common.auth_tools import ORG_ROLE_LITERAL, require_permission coord_resolution = 0.0001 # lats more than this are considered different @@ -32,13 +31,13 @@ def geo_hash(id, timestamp, long, lat): def get_collection(msg_type): """Get MongoDB collection based on message type""" - mongo_uri = os.getenv("MONGO_DB_URI") - db_name = os.getenv("MONGO_DB_NAME") + mongo_uri = api_environment.MONGO_DB_URI + db_name = api_environment.MONGO_DB_NAME if msg_type.lower() == "bsm": - coll_name = os.getenv("MONGO_PROCESSED_BSM_COLLECTION_NAME", "ProcessedBsm") + coll_name = api_environment.MONGO_PROCESSED_BSM_COLLECTION_NAME elif msg_type.lower() == "psm": - coll_name = os.getenv("MONGO_PROCESSED_PSM_COLLECTION_NAME", "ProcessedPsm") + coll_name = api_environment.MONGO_PROCESSED_PSM_COLLECTION_NAME else: return None, None, 400 @@ -89,10 +88,6 @@ def query_geo_data_mongo(pointList, start, end, msg_type): hashmap = {} count = 0 total_count = 0 - # If MAX_GEO_QUERY_RECORDS is not set or is an empty string, use 10000 as default - max_records = os.getenv("MAX_GEO_QUERY_RECORDS", 10000) or 10000 - # Convert to int in a separate step to avoid errors - max_records = int(max_records) try: logging.debug(f"Running filter: {filter} on mongo collection {coll_name}") @@ -117,7 +112,10 @@ def query_geo_data_mongo(pointList, start, end, msg_type): doc["geometry"]["coordinates"][1], ) - if message_hash not in hashmap and count < max_records: + if ( + message_hash not in hashmap + and count < api_environment.MAX_GEO_QUERY_RECORDS + ): doc.pop("_id") doc.pop("recordGeneratedAt") @@ -149,14 +147,14 @@ class RsuGeoDataSchema(Schema): class RsuGeoData(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "POST", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/rsu_geo_query.py b/services/api/src/rsu_geo_query.py index d812f648b..899ff3672 100644 --- a/services/api/src/rsu_geo_query.py +++ b/services/api/src/rsu_geo_query.py @@ -4,9 +4,8 @@ from marshmallow import Schema, fields, validate import common.pgquery as pgquery import logging -import os +import api_environment from werkzeug.exceptions import BadRequest - from common.auth_tools import ( ORG_ROLE_LITERAL, PermissionResult, @@ -97,14 +96,14 @@ class RsuGeoQuerySchema(Schema): class RsuGeoQuery(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", "Access-Control-Allow-Methods": "POST", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/rsu_online_status.py b/services/api/src/rsu_online_status.py index 08284fcc0..0d78c8ce9 100644 --- a/services/api/src/rsu_online_status.py +++ b/services/api/src/rsu_online_status.py @@ -8,7 +8,7 @@ import pytz import common.util as util import common.pgquery as pgquery -import os +import api_environment from common.auth_tools import ( ORG_ROLE_LITERAL, @@ -134,14 +134,14 @@ class RsuOnlineStatusSchema(Schema): class RsuOnlineStatus(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/rsu_querycounts.py b/services/api/src/rsu_querycounts.py index 4913423d7..84fafbebb 100644 --- a/services/api/src/rsu_querycounts.py +++ b/services/api/src/rsu_querycounts.py @@ -5,10 +5,10 @@ from datetime import datetime, timedelta import common.pgquery as pgquery import common.util as util -import os +import api_environment import logging from pymongo import MongoClient -from werkzeug.exceptions import InternalServerError, BadRequest, Forbidden +from werkzeug.exceptions import InternalServerError, Forbidden from common.auth_tools import ( ORG_ROLE_LITERAL, @@ -18,18 +18,8 @@ generate_sql_placeholders_for_list, ) -message_types = { - "bsm": "BSM", - "map": "Map", - "spat": "SPaT", - "srm": "SRM", - "ssm": "SSM", - "tim": "TIM", - "psm": "PSM", -} - -def query_rsu_counts_mongo(allowed_ips_dict, message_type, start, end): +def query_rsu_counts_aggregated(allowed_ips_dict, start, end): start_dt = util.format_date_utc(start, "DATETIME").replace( hour=0, minute=0, second=0, microsecond=0 ) @@ -38,8 +28,10 @@ def query_rsu_counts_mongo(allowed_ips_dict, message_type, start, end): ) try: - client = MongoClient(os.getenv("MONGO_DB_URI"), serverSelectionTimeoutMS=5000) - mongo_db = client[os.getenv("MONGO_DB_NAME")] + client = MongoClient( + api_environment.MONGO_DB_URI, serverSelectionTimeoutMS=5000 + ) + mongo_db = client[api_environment.MONGO_DB_NAME] collection = mongo_db["CVCounts"] except Exception as e: logging.error( @@ -47,30 +39,59 @@ def query_rsu_counts_mongo(allowed_ips_dict, message_type, start, end): ) raise Forbidden("Failed to connect to Mongo") from e - result = {} - for rsu_ip in allowed_ips_dict: - query = { - "messageType": message_types[message_type.lower()], - "rsuIp": rsu_ip, - "timestamp": { - "$gte": start_dt, - "$lt": end_dt, - }, + # MongoDB aggregation query to get message counts by RSU IP + pipeline = [ + { + "$match": { + "rsuIp": {"$in": list(allowed_ips_dict.keys())}, + "timestamp": {"$gte": start_dt, "$lt": end_dt}, + } + }, + {"$project": {"rsuIp": 1, "messageType": 1, "count": 1, "_id": 0}}, + { + "$group": { + "_id": {"rsuIp": "$rsuIp", "messageType": "$messageType"}, + "totalCount": {"$sum": "$count"}, + } + }, + { + "$group": { + "_id": "$_id.rsuIp", + "messageTypeCounts": { + "$push": {"k": "$_id.messageType", "v": "$totalCount"} + }, + } + }, + { + "$project": { + "_id": 0, + "rsuIp": "$_id", + "messageTypeCounts": {"$arrayToObject": "$messageTypeCounts"}, + } + }, + ] + + try: + logging.debug(f"Running aggregation on {collection.name}") + cursor = collection.aggregate(pipeline, allowDiskUse=False) # Keep in memory + + # Initialize result + result = { + rsu_ip: {"road": road, "messageTypeCounts": {}} + for rsu_ip, road in allowed_ips_dict.items() } - try: - logging.debug(f"Running query: {query}, on collection: {collection.name}") - response = collection.find_one(query) - if not response: - item = {"road": allowed_ips_dict[rsu_ip], "count": 0} - else: - item = {"road": allowed_ips_dict[rsu_ip], "count": response["count"]} - result[rsu_ip] = item - except Exception as e: - logging.error(f"Filter failed: {e}") - raise InternalServerError("Encountered unknown issue") from e + # Update with actual counts + for doc in cursor: + rsu_ip = doc["rsuIp"] + if rsu_ip in result: + result[rsu_ip]["messageTypeCounts"] = doc["messageTypeCounts"] + + return result - return result + except Exception as e: + logging.error(f"Aggregation failed: {e}") + raise InternalServerError("Encountered unknown issue") from e def get_organization_rsus(user: EnvironWithOrg, qualified_orgs: list[str]): @@ -118,14 +139,14 @@ class RsuQueryCountsSchema(Schema): class RsuQueryCounts(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } @@ -144,7 +165,6 @@ def get(self, permission_result: PermissionResult): if errors: abort(400, str(errors)) # Get arguments from request and set defaults if not provided - message = request.args.get("message", default="BSM") start = request.args.get( "start", default=((datetime.now() - timedelta(1)).strftime("%Y-%m-%dT%H:%M:%S")), @@ -153,18 +173,9 @@ def get(self, permission_result: PermissionResult): "end", default=((datetime.now()).strftime("%Y-%m-%dT%H:%M:%S")) ) - # Validate request with supported message types - logging.debug(f"COUNTS_MSG_TYPES: {os.getenv('COUNTS_MSG_TYPES','NOT_SET')}") - msgListStr = os.getenv("COUNTS_MSG_TYPES", "BSM,SSM,SPAT,SRM,MAP") - msgList = [msg_type.strip().title() for msg_type in msgListStr.split(",")] - if message.title() not in msgList: - raise BadRequest( - "Invalid Message Type.\nValid message types: " + ", ".join(msgList) - ) - rsu_dict = get_organization_rsus( permission_result.user, permission_result.qualified_orgs ) - data = query_rsu_counts_mongo(rsu_dict, message, start, end) + data = query_rsu_counts_aggregated(rsu_dict, start, end) return (data, 200, self.headers) diff --git a/services/api/src/rsu_querymsgfwd.py b/services/api/src/rsu_querymsgfwd.py index faca1d655..f8f57a183 100644 --- a/services/api/src/rsu_querymsgfwd.py +++ b/services/api/src/rsu_querymsgfwd.py @@ -3,8 +3,7 @@ from marshmallow import Schema, fields import common.pgquery as pgquery import common.snmp.rsu_message_forward_helpers as rsu_message_forward_helpers -import common.util as util -import os +import api_environment import logging from common.auth_tools import ( @@ -42,50 +41,9 @@ def query_snmp_msgfwd_authorized(rsu_ip: str, organization: ORG_ROLE_LITERAL): logging.debug(f'Executing query: "{query};"') data = pgquery.query_db(query, params=params) - msgfwd_configs_dict = {} - for row in data: - row = dict(row[0]) - - config_row = { - "Message Type": row["message_type"].upper(), - "IP": row["dest_ipv4"], - "Port": row["dest_port"], - "Start DateTime": util.format_date_denver_iso(row["start_datetime"]), - "End DateTime": util.format_date_denver_iso(row["end_datetime"]), - "Config Active": rsu_message_forward_helpers.active(row["active"]), - "Full WSMP": rsu_message_forward_helpers.active(row["security"]), - } - - # Based on the value of msgfwd_type, store the configuration data to match the response object of rsufwdsnmpwalk - if row["msgfwd_type"] == "rsuDsrcFwd": - msgfwd_configs_dict[row["snmp_index"]] = config_row - elif row["msgfwd_type"] == "rsuReceivedMsg": - if "rsuReceivedMsgTable" not in msgfwd_configs_dict: - msgfwd_configs_dict["rsuReceivedMsgTable"] = {} - msgfwd_configs_dict["rsuReceivedMsgTable"][row["snmp_index"]] = config_row - elif row["msgfwd_type"] == "rsuXmitMsgFwding": - if "rsuXmitMsgFwdingTable" not in msgfwd_configs_dict: - msgfwd_configs_dict["rsuXmitMsgFwdingTable"] = {} - msgfwd_configs_dict["rsuXmitMsgFwdingTable"][row["snmp_index"]] = config_row - else: - # changed the double quotes around msgfwd_type to single quotes to allow for vscode debugging to work properly - logging.warning( - f"Encountered unknown message forwarding configuration type '{row['msgfwd_type']}' for RSU '{rsu_ip}'" - ) - - # Make sure both RX and TX objects are available if the RSU ends up having NTCIP 1218 configurations - if ( - "rsuReceivedMsgTable" in msgfwd_configs_dict - and "rsuXmitMsgFwdingTable" not in msgfwd_configs_dict - ): - msgfwd_configs_dict["rsuXmitMsgFwdingTable"] = {} - elif ( - "rsuXmitMsgFwdingTable" in msgfwd_configs_dict - and "rsuReceivedMsgTable" not in msgfwd_configs_dict - ): - msgfwd_configs_dict["rsuReceivedMsgTable"] = {} - - return {"RsuFwdSnmpwalk": msgfwd_configs_dict} + return rsu_message_forward_helpers.format_snmp_msgfwd_configs( + [dict(row[0]) for row in data], rsu_ip=rsu_ip + ) # REST endpoint resource class and schema @@ -95,14 +53,14 @@ class RsuQueryMsgFwdSchema(Schema): class RsuQueryMsgFwd(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/rsu_snmp_fwd_fetch.py b/services/api/src/rsu_snmp_fwd_fetch.py new file mode 100644 index 000000000..04d0ec924 --- /dev/null +++ b/services/api/src/rsu_snmp_fwd_fetch.py @@ -0,0 +1,111 @@ +from flask import request, abort +from flask_restful import Resource +from marshmallow import Schema, fields +import common.snmp.rsu_message_forward_helpers as rsu_message_forward_helpers +import api_environment +import logging +from rsu_commands import fetch_rsu_info +from common.snmp.update_pg.update_rsu_message_forward import UpdatePostgresRsuMessageForward + +from common.auth_tools import ( + ORG_ROLE_LITERAL, + PermissionResult, + require_permission, +) + +from werkzeug.exceptions import InternalServerError, BadRequest, NotFound + + +# REST endpoint resource class and schema +class RsuSnmpFwdFetchSchema(Schema): + rsu_ip = fields.IPv4(required=True) + + +class RsuSnmpFwdFetch(Resource): + """ + Handles fetching SNMP message forwarding configurations for Roadside Units (RSUs). + This resource provides endpoints to retrieve SNMP configurations for a specified RSU, + ensuring proper permissions and schema validation, and interacting with the necessary + helper services for RSU data retrieval. + """ + options_headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + @require_permission(required_role=ORG_ROLE_LITERAL.USER) + def get(self, permission_result: PermissionResult): + """ + Handles the GET request for fetching SNMP configurations of a specified RSU (Roadside Unit) + based on the RSU IP and the organization of the requesting user. This method validates the input + parameters, fetches RSU information, and utilizes SNMP configuration methods to retrieve and + format the required information. + + :param permission_result: An instance of `PermissionResult` containing user-related + permissions data, used to determine the organization context for the operation. + :return: A tuple consisting of the formatted SNMP configuration data, the HTTP status code, + and response headers. + """ + logging.debug("RsuSnmpFwdFetch GET requested") + # Schema check for arguments + schema = RsuSnmpFwdFetchSchema() + errors = schema.validate(request.args) + if errors: + abort(400, str(errors)) + + # Get arguments from request + rsu_ip = request.args.get("rsu_ip") + organization = permission_result.user.organization + + # Fetch RSU info + rsu_info = fetch_rsu_info(rsu_ip, organization) + if not rsu_info: + raise NotFound(f"RSU IP {rsu_ip} not found in organization {organization}") + + # Call get_snmp_configs + updater = UpdatePostgresRsuMessageForward() + # get_snmp_configs expects a list of RSU dicts with specific keys. + # fetch_rsu_info must provide at least: rsu_id, snmp_username, snmp_password, snmp_version. + # UpdatePostgresRsuMessageForward.get_snmp_configs uses: ipv4_address, snmp_username, snmp_password, snmp_encrypt_pw, snmp_version, rsu_id. + required_keys = ["rsu_id", "snmp_username", "snmp_password", "snmp_version"] # "snmp_encrypt_pw" is expected but appears to be optional + missing_keys = [key for key in required_keys if not rsu_info.get(key)] + if missing_keys: + logging.error( + "RSU info for IP %s is missing required fields for SNMP config fetch: %s", + rsu_ip, + ", ".join(missing_keys), + ) + raise InternalServerError(f"RSU info missing required fields: {missing_keys}") + rsu_info_copy = rsu_info.copy() + rsu_info_copy["ipv4_address"] = rsu_ip + + try: + configs = updater.get_snmp_configs([rsu_info_copy]) + rsu_configs = configs.get(rsu_info_copy["rsu_id"]) + + if rsu_configs == "Unable to retrieve latest SNMP config": + raise InternalServerError("Unable to retrieve latest SNMP config from RSU") + if rsu_configs == "Unsupported SNMP version": + raise BadRequest("Unsupported SNMP version for direct fetch") + + return ( + rsu_message_forward_helpers.format_snmp_msgfwd_configs( + rsu_configs, rsu_ip=rsu_ip + ), + 200, + self.headers, + ) + except Exception as e: + logging.exception(f"Error fetching SNMP configs: {e}") + raise InternalServerError("Error fetching SNMP configs") from e diff --git a/services/api/src/rsu_snmpset.py b/services/api/src/rsu_snmpset.py index da2d97a34..ab8018837 100644 --- a/services/api/src/rsu_snmpset.py +++ b/services/api/src/rsu_snmpset.py @@ -10,7 +10,7 @@ msg_type_map = { "bsm": {"port": "46800", "psid": "20", "tx": False, "raw": False}, "spat": {"port": "44910", "psid": "8002", "tx": True, "raw": False}, - "map": {"port": "44920", "psid": "20", "tx": True, "raw": True}, + "map": {"port": "44920", "psid": "E0000017", "tx": True, "raw": True}, "ssm": {"port": "44900", "psid": "E0000015", "tx": True, "raw": True}, "srm": {"port": "44930", "psid": "E0000016", "tx": False, "raw": True}, "tim": {"port": "47900", "psid": "8003", "tx": True, "raw": True}, diff --git a/services/api/src/rsu_ssm_srm.py b/services/api/src/rsu_ssm_srm.py index 6fc4bb568..8cd774055 100644 --- a/services/api/src/rsu_ssm_srm.py +++ b/services/api/src/rsu_ssm_srm.py @@ -1,7 +1,7 @@ from typing import Any from flask_restful import Resource import common.util as util -import os +import api_environment import logging from datetime import datetime, timedelta from pymongo import MongoClient @@ -23,18 +23,11 @@ def query_ssm_data_mongo() -> list: start_utc = util.format_date_utc(start_date.isoformat()) try: - client: MongoClient = MongoClient( - os.getenv("MONGO_DB_URI"), serverSelectionTimeoutMS=5000 + client = MongoClient( + api_environment.MONGO_DB_URI, serverSelectionTimeoutMS=5000 ) - mongo_db_name = os.getenv("MONGO_DB_NAME") - srm_db_name = os.getenv("SSM_DB_NAME") - if not mongo_db_name or not srm_db_name: - logging.error( - "Missing one ore more environment variables for MongoDB: MONGO_DB_NAME, SSM_DB_NAME" - ) - raise Exception("Missing environment variables for MongoDB") - db = client[mongo_db_name] - collection = db[srm_db_name] + db = client[api_environment.MONGO_DB_NAME] + collection = db[api_environment.MONGO_SSM_COLLECTION_NAME] except Exception as e: logging.error( f"Failed to connect to Mongo counts collection with error message: {e}" @@ -88,18 +81,11 @@ def query_srm_data_mongo() -> list: start_utc = util.format_date_utc(start_date.isoformat()) try: - client: MongoClient = MongoClient( - os.getenv("MONGO_DB_URI"), serverSelectionTimeoutMS=5000 + client = MongoClient( + api_environment.MONGO_DB_URI, serverSelectionTimeoutMS=5000 ) - mongo_db_name = os.getenv("MONGO_DB_NAME") - srm_db_name = os.getenv("SRM_DB_NAME") - if not mongo_db_name or not srm_db_name: - logging.error( - "Missing one ore more environment variables for MongoDB: MONGO_DB_NAME, SSM_DB_NAME" - ) - raise Exception("Missing environment variables for MongoDB") - db = client[mongo_db_name] - collection = db[srm_db_name] + db = client[api_environment.MONGO_DB_NAME] + collection = db[api_environment.MONGO_SRM_COLLECTION_NAME] except Exception as e: logging.error( f"Failed to connect to Mongo counts collection with error message: {e}" @@ -156,14 +142,14 @@ def filter_results_by_ip_address( class RsuSsmSrmData(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/src/rsu_upgrade.py b/services/api/src/rsu_upgrade.py index 1bcf864bc..3df428356 100644 --- a/services/api/src/rsu_upgrade.py +++ b/services/api/src/rsu_upgrade.py @@ -1,7 +1,8 @@ import common.pgquery as pgquery import json import logging -import os + +import api_environment import requests from werkzeug.exceptions import Conflict, NotImplemented @@ -44,7 +45,7 @@ def check_for_upgrade(rsu_ip): def mark_rsu_for_upgrade(rsu_ip): - if os.getenv("FIRMWARE_MANAGER_ENDPOINT") is None: + if api_environment.FIRMWARE_MANAGER_ENDPOINT is None: raise NotImplemented( # noqa: F901 "The firmware manager is not supported for this CV Manager deployment" ) @@ -52,7 +53,7 @@ def mark_rsu_for_upgrade(rsu_ip): # Verify requested target RSU is eligible for upgrade and determine next upgrade upgrade_info = check_for_upgrade(rsu_ip) - if upgrade_info["upgrade_available"] is False: + if not upgrade_info["upgrade_available"]: raise Conflict( f"Requested RSU '{rsu_ip}' is already up to date with the latest firmware" ) @@ -63,7 +64,7 @@ def mark_rsu_for_upgrade(rsu_ip): logging.info(f"Initiating firmware upgrade with the firmware manager for {rsu_ip}") # Environment variable FIRMWARE_MANAGER_ENDPOINT must contain "http://" and port - firmware_manager_endpoint = os.getenv("FIRMWARE_MANAGER_ENDPOINT") + firmware_manager_endpoint = api_environment.FIRMWARE_MANAGER_ENDPOINT post_body = {"rsu_ip": rsu_ip} response = requests.post( f"{firmware_manager_endpoint}/init_firmware_upgrade", json=post_body diff --git a/services/api/src/rsuinfo.py b/services/api/src/rsuinfo.py deleted file mode 100644 index 2b7c07261..000000000 --- a/services/api/src/rsuinfo.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Any -from flask_restful import Resource -import logging -import common.pgquery as pgquery -import os - -from common.auth_tools import ( - ORG_ROLE_LITERAL, - EnvironWithOrg, - PermissionResult, - require_permission, - generate_sql_placeholders_for_list, -) - - -def get_rsu_data(user: EnvironWithOrg, qualified_orgs: list[str]): - - # Execute the query and fetch all results - query = ( - "SELECT jsonb_build_object('type', 'Feature', 'id', row.rsu_id, 'geometry', ST_AsGeoJSON(row.geography)::jsonb, 'properties', to_jsonb(row)) " - "FROM (" - "SELECT rd.rsu_id, rd.geography, rd.milepost, rd.ipv4_address, rd.serial_number, rd.primary_route, rm.name AS model_name, man.name AS manufacturer_name " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rd.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - ) - - where_clause = None - params: dict[str, Any] = {} - if user.organization: - where_clause = "ron_v.name = :user_org " - params["user_org"] = user.organization - elif not user.user_info.super_user: - org_names_placeholder, _ = generate_sql_placeholders_for_list( - qualified_orgs, params_to_update=params - ) - where_clause = f"ron_v.name IN ({org_names_placeholder}) " - if where_clause: - query += f"WHERE {where_clause}" - query += ") as row" - - logging.debug(f'Executing query "{query};"') - data = pgquery.query_db(query, params=params) - - logging.info("Parsing results...") - result: dict[str, Any] = {"rsuList": []} - for row in data: - row = dict(row[0]) - result["rsuList"].append(row) - return result - - -# REST endpoint resource class -class RsuInfo(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization,Organization", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=ORG_ROLE_LITERAL.USER, - ) - def get(self, permission_result: PermissionResult): - logging.debug("RsuInfo GET requested") - return ( - get_rsu_data(permission_result.user, permission_result.qualified_orgs), - 200, - self.headers, - ) diff --git a/services/api/src/smtp_error_handler.py b/services/api/src/smtp_error_handler.py index d3e1ad047..1415ccd07 100644 --- a/services/api/src/smtp_error_handler.py +++ b/services/api/src/smtp_error_handler.py @@ -1,93 +1,53 @@ -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - import logging - -from logging.handlers import SMTPHandler -import smtplib +from logging import Handler import datetime -import ssl -import os - - -def get_subscribed_users(): - emails = os.environ["CSM_EMAILS_TO_SEND_TO"].split(",") - return emails +import traceback +import api_environment +from common.email_api import EmailApi +from common.keycloak_api import KeycloakServiceAccountApi def configure_error_emails(app): - mail_handler = SMTP_SSLHandler( - mailhost=[ - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"], - int(os.environ["CSM_TARGET_SMTP_SERVER_PORT"]), - ], - fromaddr=os.environ["CSM_EMAIL_TO_SEND_FROM"], - toaddrs=[], - subject="Automated CV Manager API Error", - credentials=[ - os.environ["CSM_EMAIL_APP_USERNAME"], - os.environ["CSM_EMAIL_APP_PASSWORD"], - ], - secure=(), - ) + mail_handler = ErrorEmailHandler() mail_handler.setLevel(logging.ERROR) - # this seems weird, but it's the only way I can figure out how to include the stack trace info. This command appends the stack trace to the end of the self.format(record) call. - mail_handler.setFormatter(logging.Formatter("")) app.logger.addHandler(mail_handler) -def get_environment_name(instance_connection_name: str) -> str: - try: - return instance_connection_name.split(":")[0] - except (AttributeError, IndexError): - return str(instance_connection_name) - - -class SMTP_SSLHandler(SMTPHandler): - def __init__( - self, mailhost, fromaddr, toaddrs, subject, credentials=None, secure=None - ): - super(SMTP_SSLHandler, self).__init__( - mailhost, fromaddr, toaddrs, subject, credentials, secure +class ErrorEmailHandler(Handler): + def __init__(self): + super().__init__() # initialize handler + self.email_api = EmailApi( + api_environment.IAPI_ENDPOINT, + kc_api=KeycloakServiceAccountApi( + api_environment.KEYCLOAK_ENDPOINT, + api_environment.KEYCLOAK_REALM, + api_environment.KC_SA_CLIENT_ID, + api_environment.KC_SA_CLIENT_SECRET, + ), ) def emit(self, record): try: - subscribed_users = get_subscribed_users() - if not hasattr(record, "asctime"): # For some reason, asctime is not always available. So we update it to the current time in the same format (2023-08-23 15:39:29,115) record.asctime = datetime.datetime.now().strftime( "%Y-%m-%d %H:%M:%S,%f" )[:-3] - body_content = open("./error_email/error_email_template.html").read() - - EMAIL_KEYS = { - "ENVIRONMENT": os.environ["ENVIRONMENT_NAME"], - "ERROR_MESSAGE": self.format(record).replace("\n", "
"), - "ERROR_TIME": str(record.asctime), - "LOGS_LINK": os.environ["LOGS_LINK"], - } - - context = ssl._create_unverified_context() - smtp = smtplib.SMTP(host=self.mailhost, port=self.mailport) - smtp.starttls(context=context) - smtp.ehlo() - smtp.login(self.username, self.password) - - for email in subscribed_users: - message = MIMEMultipart() - message["Subject"] = self.subject - message["From"] = self.fromaddr - message["To"] = email - - for key, value in EMAIL_KEYS.items(): - body_content = body_content.replace(f"##_{key}_##", value) - message.attach(MIMEText(body_content, "html")) - smtp.sendmail(self.fromaddr, email, message.as_string()) - smtp.quit() - - logging.debug(f"Successfully sent error email to {subscribed_users}") - except Exception as e: - logging.exception(e) + # Ensure stack_trace is always a string and preserve raw newlines. + if record.exc_info: + stack_trace = "".join(traceback.format_exception(*record.exc_info)) + elif record.exc_text: + stack_trace = record.exc_text + else: + stack_trace = "No stack trace available" + + self.email_api.send_api_error_email( + error_message=record.getMessage(), + stack_trace=stack_trace, + timestamp=record.asctime, + logs_link=api_environment.LOGS_LINK, + ) + + except Exception: + self.handleError(record) diff --git a/services/api/src/userauth.py b/services/api/src/userauth.py deleted file mode 100644 index f9899cc46..000000000 --- a/services/api/src/userauth.py +++ /dev/null @@ -1,35 +0,0 @@ -import os - -from flask_restful import Resource - -from common.auth_tools import PermissionResult, require_permission - - -class UserAuth(Resource): - options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Max-Age": "3600", - } - - headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], - "Content-Type": "application/json", - } - - def options(self): - # CORS support - return ("", 204, self.options_headers) - - @require_permission( - required_role=None, - ) - def get(self, permission_result: PermissionResult): - # Check for user info and return data - - return ( - permission_result.user.user_info.to_dict(), - 200, - self.headers, - ) diff --git a/services/api/src/wzdx_feed.py b/services/api/src/wzdx_feed.py index 25e75f0b8..e5befd1e4 100644 --- a/services/api/src/wzdx_feed.py +++ b/services/api/src/wzdx_feed.py @@ -2,7 +2,9 @@ import logging import requests import json -import os +import api_environment + +from common.auth_tools import require_permission from common.auth_tools import require_permission @@ -11,7 +13,7 @@ def get_wzdx_data(): # Execute the query and fetch all results return json.loads( requests.get( - f'https://{os.getenv("WZDX_ENDPOINT")}/api/v1/wzdx?apiKey={os.getenv("WZDX_API_KEY")}' + f"https://{api_environment.WZDX_ENDPOINT}/api/v1/wzdx?apiKey={api_environment.WZDX_API_KEY}" ).content.decode("utf-8") ) @@ -19,14 +21,14 @@ def get_wzdx_data(): # REST endpoint resource class class WzdxFeed(Resource): options_headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Access-Control-Allow-Headers": "Content-Type,Authorization", "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "3600", } headers = { - "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Origin": api_environment.CORS_DOMAIN, "Content-Type": "application/json", } diff --git a/services/api/tests/data/admin_new_rsu_data.py b/services/api/tests/data/admin_new_rsu_data.py deleted file mode 100644 index 34ad23a57..000000000 --- a/services/api/tests/data/admin_new_rsu_data.py +++ /dev/null @@ -1,137 +0,0 @@ -import multidict - -##################################### request_data ########################################### - -request_params_good = multidict.MultiDict([]) - -request_json_good = { - "ip": "10.0.0.1", - "geo_position": {"longitude": -100.0, "latitude": 38.0}, - "milepost": 900.1, - "primary_route": "Test Route", - "serial_number": "test", - "model": "Commsignia", - "scms_id": "", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations": ["Test Org"], -} - -request_json_bad = { - "ip": "10.0.0.1", - "geo_position": {"longitude": -100.0}, - "milepost": 900.1, - "primary_route": "Test Route", - "serial_number": "test", - "model": "Commsignia", - "scms_id": "", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations": ["Test Org"], -} - -##################################### test_data ########################################### - -good_input = { - "primary_route": "test route", - "model": "test test", - "serial_number": "test", - "scms_id": "test", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", -} - -bad_input = { - "primary_route": "test route--", - "model": "test test@#", - "serial_number": "test", - "scms_id": "test", - "ssh_credential_group": "test*&&", - "snmp_credential_group": "test", - "snmp_version_group": "test", -} - -mock_post_body_commsignia = { - "ip": "10.0.0.1", - "geo_position": {"latitude": 39.896450, "longitude": -104.984451}, - "milepost": 900.52, - "primary_route": "Test Route", - "serial_number": "test", - "model": "Commsignia RSU", - "scms_id": "", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations": ["test"], -} - -mock_post_body_yunex = { - "ip": "10.0.0.1", - "geo_position": {"latitude": 39.896450, "longitude": -104.984451}, - "milepost": 900.52, - "primary_route": "Test Route", - "serial_number": "test", - "model": "Yunex RSU", - "scms_id": "custom", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations": ["test"], -} - -mock_post_body_yunex_no_scms = { - "ip": "10.0.0.1", - "geo_position": {"latitude": 39.896450, "longitude": -104.984451}, - "milepost": 900.52, - "primary_route": "Test Route", - "serial_number": "test", - "model": "Yunex RSU", - "scms_id": "", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations": ["test"], -} - -rsu_query_commsignia = ( - "INSERT INTO public.rsus(geography, milepost, ipv4_address, serial_number, primary_route, model, credential_id, snmp_credential_id, snmp_protocol_id, iss_scms_id) " - "VALUES (" - "ST_GeomFromText('POINT(-104.984451 39.89645)'), " - "900.52, " - "'10.0.0.1', " - "'test', " - "'Test Route', " - "(SELECT rsu_model_id FROM public.rsu_models WHERE name = 'RSU'), " - "(SELECT credential_id FROM public.rsu_credentials WHERE nickname = 'test'), " - "(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = 'test'), " - "(SELECT snmp_protocol_id FROM public.snmp_protocols WHERE nickname = 'test'), " - "'test'" - ")" -) - -rsu_query_yunex = ( - "INSERT INTO public.rsus(geography, milepost, ipv4_address, serial_number, primary_route, model, credential_id, snmp_credential_id, snmp_protocol_id, iss_scms_id) " - "VALUES (" - "ST_GeomFromText('POINT(-104.984451 39.89645)'), " - "900.52, " - "'10.0.0.1', " - "'test', " - "'Test Route', " - "(SELECT rsu_model_id FROM public.rsu_models WHERE name = 'RSU'), " - "(SELECT credential_id FROM public.rsu_credentials WHERE nickname = 'test'), " - "(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = 'test'), " - "(SELECT snmp_protocol_id FROM public.snmp_protocols WHERE nickname = 'test'), " - "'custom'" - ")" -) - -rsu_org_query = ( - "INSERT INTO public.rsu_organization(rsu_id, organization_id) VALUES" - " (" - "(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.0.0.1'), " - "(SELECT organization_id FROM public.organizations WHERE name = 'test')" - ")" -) diff --git a/services/api/tests/data/admin_new_user_data.py b/services/api/tests/data/admin_new_user_data.py deleted file mode 100644 index 542ea4c0a..000000000 --- a/services/api/tests/data/admin_new_user_data.py +++ /dev/null @@ -1,53 +0,0 @@ -import multidict - -##################################### request_data ########################################### - -request_params_good = multidict.MultiDict([]) - -request_json_good = { - "email": "jdoe@example.com", - "first_name": "John", - "last_name": "Doe", - "super_user": True, - "organizations": [{"name": "Test Org", "role": "operator"}], -} - -request_json_bad = { - "email": "jdoe@example.com", - "first_name": "John", - "last_name": "Doe", - "super_user": True, - "organizations": ["Test Org"], -} - -##################################### test_data ########################################### - -good_input = { - "email": "jdoe@example.com", - "first_name": "John", - "last_name": "Doe", - "super_user": True, - "organizations": [{"name": "Test Org", "role": "operator"}], -} - -bad_input = { - "email": "j--doe@exa@mple.com", - "first_name": "John--", - "last_name": "Doe@#", - "super_user": True, - "organizations": [{"name": "Test Org##", "role": "operator"}], -} - -user_insert_query = ( - "INSERT INTO public.users(email, first_name, last_name, super_user, created_timestamp) " - "VALUES ('jdoe@example.com', 'John', 'Doe', '1', 1678901234000)" -) - -user_org_insert_query = ( - "INSERT INTO public.user_organization(user_id, organization_id, role_id) VALUES" - " (" - "(SELECT user_id FROM public.users WHERE email = 'jdoe@example.com'), " - "(SELECT organization_id FROM public.organizations WHERE name = 'Test Org'), " - "(SELECT role_id FROM public.roles WHERE name = 'operator')" - ")" -) diff --git a/services/api/tests/data/admin_org_data.py b/services/api/tests/data/admin_org_data.py index 154f503f9..1bc18575e 100644 --- a/services/api/tests/data/admin_org_data.py +++ b/services/api/tests/data/admin_org_data.py @@ -96,7 +96,15 @@ ] get_org_data_rsu_return = [ - ({"ipv4_address": "10.0.0.1", "primary_route": "test", "milepost": "test"},), + ( + { + "ipv4_address": "10.0.0.1", + "primary_route": "test", + "milepost": "test", + "tim_deposit": False, + "snmp_monitoring": False, + }, + ), ] get_org_data_intersection_return = [ @@ -118,7 +126,15 @@ "role": "user", } ], - "org_rsus": [{"ip": "10.0.0.1", "primary_route": "test", "milepost": "test"}], + "org_rsus": [ + { + "ip": "10.0.0.1", + "primary_route": "test", + "milepost": "test", + "tim_deposit": False, + "snmp_monitoring": False, + } + ], "org_intersections": [ { "intersection_id": 1234, @@ -146,12 +162,14 @@ get_org_data_rsu_sql = ( "SELECT to_jsonb(row) " "FROM (" - "SELECT r.ipv4_address, r.primary_route, r.milepost " + "SELECT r.ipv4_address, r.primary_route, r.milepost, r.tim_deposit, r.snmp_monitoring " "FROM public.organizations AS org " "JOIN (" - "SELECT ro.organization_id, rsus.ipv4_address, rsus.primary_route, rsus.milepost " + "SELECT ro.organization_id, rsus.ipv4_address, rsus.primary_route, rsus.milepost, " + "COALESCE(opts.tim_deposit, FALSE) as tim_deposit, COALESCE(opts.snmp_monitoring, FALSE) as snmp_monitoring " "FROM public.rsu_organization ro " - "JOIN public.rsus ON ro.rsu_id = rsus.rsu_id" + "JOIN public.rsus ON ro.rsu_id = rsus.rsu_id " + "LEFT JOIN public.rsu_options opts ON rsus.rsu_id = opts.rsu_id" ") r ON r.organization_id = org.organization_id " "WHERE org.name = :org_name" ") as row" diff --git a/services/api/tests/data/admin_rsu_data.py b/services/api/tests/data/admin_rsu_data.py deleted file mode 100644 index 71d1c41d1..000000000 --- a/services/api/tests/data/admin_rsu_data.py +++ /dev/null @@ -1,182 +0,0 @@ -import multidict - -##################################### request data ########################################### - -request_environ = multidict.MultiDict([]) - -request_args_rsu_good = {"rsu_ip": "10.0.0.1"} -request_args_all_good = {"rsu_ip": "all"} -request_args_str_bad = {"rsu_ip": 5} -request_args_ipv4_bad = {"rsu_ip": "test"} - -request_json_good = { - "orig_ip": "10.0.0.1", - "ip": "10.0.0.1", - "geo_position": {"longitude": -100.0, "latitude": 38.0}, - "milepost": 900.1, - "primary_route": "Test Route", - "serial_number": "test", - "model": "manufacturer model", - "scms_id": "test", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations_to_add": ["Test Org2"], - "organizations_to_remove": ["Test Org1"], -} - -request_json_bad = { - "orig_ip": "10.0.0.1", - "ip": "10.0.0.1", - "geo_position": {"longitude": "test", "latitude": 38.0}, - "milepost": 900.1, - "primary_route": "Test Route", - "serial_number": "test", - "model": "manufacturer model", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "organizations_to_add": ["Test Org"], - "organizations_to_remove": [], -} - - -##################################### function data ########################################### - -get_rsu_data_return = [ - ( - { - "ipv4_address": "10.11.81.12", - "latitude": 45, - "longitude": 45, - "milepost": 45, - "primary_route": "test route", - "serial_number": "test", - "model": "test", - "iss_scms_id": "test", - "ssh_credential": "ssh test", - "snmp_credential": "snmp test", - "snmp_version": "snmp test", - "org_name": "test org", - }, - ), -] - -expected_get_rsu_all = [ - { - "ip": "10.11.81.12", - "geo_position": {"latitude": 45, "longitude": 45}, - "milepost": 45, - "primary_route": "test route", - "serial_number": "test", - "model": "test", - "scms_id": "test", - "ssh_credential_group": "ssh test", - "snmp_credential_group": "snmp test", - "snmp_version_group": "snmp test", - "organizations": ["test org"], - } -] - -expected_get_rsu_query_all = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT ipv4_address, ST_X(geography::geometry) AS longitude, ST_Y(geography::geometry) AS latitude, " - "milepost, primary_route, serial_number, iss_scms_id, concat(man.name, ' ',rm.name) AS model, " - "rsu_cred.nickname AS ssh_credential, snmp_cred.nickname AS snmp_credential, snmp_ver.nickname AS snmp_version, org.name AS org_name " - "FROM public.rsus " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rsus.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - "JOIN public.rsu_credentials AS rsu_cred ON rsu_cred.credential_id = rsus.credential_id " - "JOIN public.snmp_credentials AS snmp_cred ON snmp_cred.snmp_credential_id = rsus.snmp_credential_id " - "JOIN public.snmp_protocols AS snmp_ver ON snmp_ver.snmp_protocol_id = rsus.snmp_protocol_id " - "JOIN public.rsu_organization AS ro ON ro.rsu_id = rsus.rsu_id " - "JOIN public.organizations AS org ON org.organization_id = ro.organization_id " - ") as row" -) - -expected_get_rsu_query_one = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT ipv4_address, ST_X(geography::geometry) AS longitude, ST_Y(geography::geometry) AS latitude, " - "milepost, primary_route, serial_number, iss_scms_id, concat(man.name, ' ',rm.name) AS model, " - "rsu_cred.nickname AS ssh_credential, snmp_cred.nickname AS snmp_credential, snmp_ver.nickname AS snmp_version, org.name AS org_name " - "FROM public.rsus " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rsus.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - "JOIN public.rsu_credentials AS rsu_cred ON rsu_cred.credential_id = rsus.credential_id " - "JOIN public.snmp_credentials AS snmp_cred ON snmp_cred.snmp_credential_id = rsus.snmp_credential_id " - "JOIN public.snmp_protocols AS snmp_ver ON snmp_ver.snmp_protocol_id = rsus.snmp_protocol_id " - "JOIN public.rsu_organization AS ro ON ro.rsu_id = rsus.rsu_id " - "JOIN public.organizations AS org ON org.organization_id = ro.organization_id" - " WHERE ipv4_address = :rsu_ip" - ") as row" -) - -modify_rsu_sql = ( - "UPDATE public.rsus SET " - "geography=ST_GeomFromText('POINT(' || :geo_position_longitude || ' ' || :geo_position_latitude || ')'), " - "milepost=:milepost, " - "ipv4_address=:rsu_ip, " - "serial_number=:serial_number, " - "primary_route=:primary_route, " - "model=(SELECT rsu_model_id FROM public.rsu_models WHERE name = :model), " - "credential_id=(SELECT credential_id FROM public.rsu_credentials WHERE nickname = :ssh_credential_group), " - "snmp_credential_id=(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = :snmp_credential_group), " - "snmp_protocol_id=(SELECT snmp_protocol_id FROM public.snmp_protocols WHERE nickname = :snmp_version_group), " - "iss_scms_id=:scms_id " - "WHERE ipv4_address=:orig_ip", - { - "rsu_ip": "10.0.0.1", - "geo_position_longitude": -100.0, - "geo_position_latitude": 38.0, - "milepost": 900.1, - "serial_number": "test", - "primary_route": "Test Route", - "model": "model", - "ssh_credential_group": "test", - "snmp_credential_group": "test", - "snmp_version_group": "test", - "scms_id": "test", - "orig_ip": "10.0.0.1", - }, -) - -add_org_sql = ( - "INSERT INTO public.rsu_organization(rsu_id, organization_id) VALUES" - " (" - "(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip), " - "(SELECT organization_id FROM public.organizations WHERE name = :org_name_0)" - ")", - {"rsu_ip": "10.0.0.1", "org_name_0": "Test Org2"}, -) - -remove_org_sql = ( - "DELETE FROM public.rsu_organization WHERE " - "rsu_id = (SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip) " - "AND organization_id IN (SELECT organization_id FROM public.organizations WHERE name IN (:org_name_0))", - {"rsu_ip": "10.0.0.1", "org_name_0": "Test Org1"}, -) - -delete_rsu_calls = [ - ( - "DELETE FROM public.rsu_organization WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)", - {"rsu_ip": "10.11.81.12"}, - ), - ( - "DELETE FROM public.ping WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)", - {"rsu_ip": "10.11.81.12"}, - ), - ( - "DELETE FROM public.scms_health WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)", - {"rsu_ip": "10.11.81.12"}, - ), - ( - "DELETE FROM public.snmp_msgfwd_config WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = :rsu_ip)", - {"rsu_ip": "10.11.81.12"}, - ), - ( - "DELETE FROM public.rsus WHERE ipv4_address = :rsu_ip", - {"rsu_ip": "10.11.81.12"}, - ), -] diff --git a/services/api/tests/data/admin_user_data.py b/services/api/tests/data/admin_user_data.py deleted file mode 100644 index 4fec0b9c4..000000000 --- a/services/api/tests/data/admin_user_data.py +++ /dev/null @@ -1,147 +0,0 @@ -import multidict - -##################################### request data ########################################### - -request_environ = multidict.MultiDict([]) - -request_args_good = {"user_email": "test@gmail.com"} - -request_args_bad = {"user_email": 5} - -request_json_good = { - "orig_email": "test@gmail.com", - "email": "test@gmail.com", - "first_name": "test", - "last_name": "test", - "super_user": True, - "organizations_to_add": [{"name": "Test Org3", "role": "admin"}], - "organizations_to_modify": [{"name": "Test Org2", "role": "user"}], - "organizations_to_remove": [{"name": "Test Org1", "role": "user"}], -} - -request_json_bad = { - "orig_email": "test@gmail.com", - "email": "test@gmail.com", - "first_name": "test", - "last_name": "test", - "super_user": True, - "organizations_to_add": [{"name": "Test Org3", "role": "admin"}], - "organizations_to_remove": [{"name": "Test Org1", "role": "user"}], -} - -request_json_unsafe_input = { - "orig_email": "test@gmail.com", - "email": "test@gmail.com", - "first_name": "test", - "last_name": "tes--t", - "super_user": True, - "organizations_to_add": [{"name": "Test Org3#@", "role": "adm#!in"}], - "organizations_to_modify": [{"name": "Test O%@!rg2", "role": "user"}], - "organizations_to_remove": [{"name": "Test O!##rg1", "role": "!#user"}], -} - -##################################### function data ########################################### - -get_user_data_return = [ - ( - { - "email": "test@gmail.com", - "first_name": "test", - "last_name": "test", - "super_user": "1", - "name": "test org", - "role": "admin", - }, - ), -] - -get_user_data_expected = [ - { - "email": "test@gmail.com", - "first_name": "test", - "last_name": "test", - "super_user": True, - "organizations": [{"name": "test org", "role": "admin"}], - } -] - -expected_get_user_query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT u.email, u.first_name, u.last_name, u.super_user, org.name, roles.name AS role " - "FROM public.users u " - "LEFT JOIN public.user_organization AS uo ON uo.user_id = u.user_id " - "LEFT JOIN public.organizations AS org ON org.organization_id = uo.organization_id " - "LEFT JOIN public.roles ON roles.role_id = uo.role_id " - ") as row" -) - -expected_get_user_query_one = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT u.email, u.first_name, u.last_name, u.super_user, org.name, roles.name AS role " - "FROM public.users u " - "LEFT JOIN public.user_organization AS uo ON uo.user_id = u.user_id " - "LEFT JOIN public.organizations AS org ON org.organization_id = uo.organization_id " - "LEFT JOIN public.roles ON roles.role_id = uo.role_id" - " WHERE u.email = :user_email" - ") as row" -) -expected_get_user_query_one_params = {"user_email": "test@gmail.com"} - -modify_user_sql = ( - "UPDATE public.users SET " - "email=:email, " - "first_name=:first_name, " - "last_name=:last_name, " - "super_user=:super_user " - "WHERE email=:orig_email" -) -modify_user_params = { - "email": "test@gmail.com", - "first_name": "test", - "last_name": "test", - "super_user": "1", - "orig_email": "test@gmail.com", -} - -add_org_sql = ( - "INSERT INTO public.user_organization(user_id, organization_id, role_id) VALUES" - " (" - "(SELECT user_id FROM public.users WHERE email = :email), " - "(SELECT organization_id FROM public.organizations WHERE name = :org_name_0), " - "(SELECT role_id FROM public.roles WHERE name = :org_role_0)" - ")" -) -add_org_params = { - "email": "test@gmail.com", - "org_name_0": "Test Org3", - "org_role_0": "admin", -} - -modify_org_sql = ( - "UPDATE public.user_organization " - "SET role_id = (SELECT role_id FROM public.roles WHERE name = :role) " - "WHERE user_id = (SELECT user_id FROM public.users WHERE email = :email) " - "AND organization_id = (SELECT organization_id FROM public.organizations WHERE name = :org_name)" -) -modify_org_params = { - "email": "test@gmail.com", - "org_name": "Test Org2", - "role": "user", -} - -remove_org_sql = ( - "DELETE FROM public.user_organization WHERE " - "user_id = (SELECT user_id FROM public.users WHERE email = :email) " - "AND organization_id IN (SELECT organization_id FROM public.organizations WHERE name IN (:org_name_0))" -) -remove_org_params = { - "email": "test@gmail.com", - "org_name_0": "Test Org1", -} - -delete_user_calls = [ - "DELETE FROM public.user_organization WHERE user_id = (SELECT user_id FROM public.users WHERE email = :email)", - "DELETE FROM public.users WHERE email = :email", -] diff --git a/services/api/tests/data/contact_support_data.py b/services/api/tests/data/contact_support_data.py deleted file mode 100644 index d21948da1..000000000 --- a/services/api/tests/data/contact_support_data.py +++ /dev/null @@ -1,14 +0,0 @@ -CSM_EMAIL_TO_SEND_FROM = "test@test.com" -CSM_EMAIL_APP_USERNAME = "testusername" -CSM_EMAIL_APP_PASSWORD = "testpassword" - -EMAIL_REPLY_EMAIL = "reply-to@test.com" -EMAIL_SUBJECT = "Test Email sent with `send_email.py`" -EMAIL_MESSAGE = "This is a test email sent with `send_email.py`" - - -contact_support_data = { - "subject": EMAIL_SUBJECT, - "message": EMAIL_MESSAGE, - "email": EMAIL_REPLY_EMAIL, -} diff --git a/services/api/tests/data/iss_scms_status_data.py b/services/api/tests/data/iss_scms_status_data.py deleted file mode 100644 index 0c14cebba..000000000 --- a/services/api/tests/data/iss_scms_status_data.py +++ /dev/null @@ -1,51 +0,0 @@ -import multidict - -##################################### request_data ########################################### - -request_params_good = multidict.MultiDict( - [ - ("user_info", {"organizations": [{"name": "Test", "role": "user"}]}), - ("organization", "Test"), - ] -) - -###################################### Single Result ########################################## -return_value_single_result = [ - ({"ip": "10.0.0.1", "health": "1", "expiration": "2022-11-02T00:00:00.00Z"},) -] - -return_value_single_null_result = [ - ({"ip": "10.0.0.1", "health": None, "expiration": None},) -] - -expected_rsu_data_single_result = { - "10.0.0.1": {"health": "1", "expiration": "11/01/2022 06:00:00 PM"} -} - -expected_rsu_data_single_null_result = {"10.0.0.1": None} - -return_value_multiple_result = [ - ({"ip": "10.0.0.1", "health": "1", "expiration": "2022-11-02T00:00:00.00Z"},), - ({"ip": "10.0.0.2", "health": "0", "expiration": "2022-11-03T00:00:00.00Z"},), -] - -expected_rsu_data_multiple_result = { - "10.0.0.1": {"expiration": "11/01/2022 06:00:00 PM", "health": "1"}, - "10.0.0.2": {"expiration": "11/02/2022 06:00:00 PM", "health": "0"}, -} - -expectedQuery = ( - "SELECT jsonb_build_object('ip', rd.ipv4_address, 'health', scms_health_data.health, 'expiration', scms_health_data.expiration) " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "LEFT JOIN (" - "SELECT a.rsu_id, a.health, a.timestamp, a.expiration " - "FROM (" - "SELECT sh.rsu_id, sh.health, sh.timestamp, sh.expiration, ROW_NUMBER() OVER (PARTITION BY sh.rsu_id order by sh.timestamp DESC) AS row_id " - "FROM public.scms_health AS sh" - ") AS a " - "WHERE a.row_id <= 1 ORDER BY rsu_id" - ") AS scms_health_data ON rd.rsu_id = scms_health_data.rsu_id " - "WHERE ron_v.name = :org_name " - "ORDER BY rd.ipv4_address" -) diff --git a/services/api/tests/data/rsu_error_summary_data.py b/services/api/tests/data/rsu_error_summary_data.py deleted file mode 100644 index cce2fd972..000000000 --- a/services/api/tests/data/rsu_error_summary_data.py +++ /dev/null @@ -1,17 +0,0 @@ -CSM_EMAIL_TO_SEND_FROM = "test@test.com" -CSM_EMAIL_APP_USERNAME = "testusername" -CSM_EMAIL_APP_PASSWORD = "testpassword" -DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS = "smtp.gmail.com" -DEFAULT_CSM_TARGET_SMTP_SERVER_PORT = 587 - - -rsu_error_summary_data_good = { - "subject": "test_subject", - "message": "test_message", - "emails": "some-reply@test.com", -} - -rsu_error_summary_data_bad = { - "subject": "test_subject", - "message": "test_message", -} diff --git a/services/api/tests/data/rsu_info_data.py b/services/api/tests/data/rsu_info_data.py index ad300d4e1..abae29049 100644 --- a/services/api/tests/data/rsu_info_data.py +++ b/services/api/tests/data/rsu_info_data.py @@ -27,6 +27,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00055", "FirmwareVersion": 1, + "tim_deposit": True, }, }, ) @@ -49,6 +50,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00055", "FirmwareVersion": 1, + "tim_deposit": True, }, } ] @@ -71,6 +73,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00055", "FirmwareVersion": 1, + "tim_deposit": True, }, }, ), @@ -90,6 +93,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00060", "FirmwareVersion": 1, + "tim_deposit": True, }, }, ), @@ -109,6 +113,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00084", "FirmwareVersion": 1, + "tim_deposit": True, }, }, ), @@ -131,6 +136,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00055", "FirmwareVersion": 1, + "tim_deposit": True, }, }, { @@ -148,6 +154,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00060", "FirmwareVersion": 1, + "tim_deposit": True, }, }, { @@ -165,6 +172,7 @@ "PrimaryRoute": "C-470", "SerialNumber": "PEM00084", "FirmwareVersion": 1, + "tim_deposit": True, }, }, ] diff --git a/services/api/tests/src/conftest.py b/services/api/tests/src/conftest.py new file mode 100644 index 000000000..5d03943b0 --- /dev/null +++ b/services/api/tests/src/conftest.py @@ -0,0 +1,26 @@ +import os +import sys +from os.path import dirname, join, abspath + +# Add the services and api/src directories to the path so that imports work +# during testing. This is necessary because of the project structure. +current_dir = dirname(abspath(__file__)) +# current_dir is .../services/api/tests/src +root_dir = abspath(join(current_dir, "..", "..", "..", "..")) + +sys.path.insert(0, join(root_dir, "services", "common")) +sys.path.insert(0, join(root_dir, "services", "api", "src")) +sys.path.insert(0, join(root_dir, "services")) + +os.environ["KEYCLOAK_ENDPOINT"] = "keycloak-endpoint" +os.environ["KEYCLOAK_REALM"] = "keycloak-realm" +os.environ["KEYCLOAK_API_CLIENT_ID"] = "keycloak-api-client-id" +os.environ["KEYCLOAK_API_CLIENT_SECRET_KEY"] = "keycloak-api-client-secret-key" +os.environ["CSM_AUTH_ENABLED"] = "false" +os.environ["WZDX_ENDPOINT"] = "wzdx-endpoint" +os.environ["WZDX_API_KEY"] = "wzdx-api-key" +os.environ["CORS_DOMAIN"] = "test.com" +os.environ["ENABLE_ERROR_EMAILS"] = "true" +os.environ["IAPI_ENDPOINT"] = "http://localhost:8089" +os.environ["KC_SA_CLIENT_ID"] = "sa_cvmanager_python_api" +os.environ["KC_SA_CLIENT_SECRET"] = "sa-cvmanager-python-api-secret" diff --git a/services/api/tests/src/pytest.ini b/services/api/tests/src/pytest.ini new file mode 100644 index 000000000..422492d88 --- /dev/null +++ b/services/api/tests/src/pytest.ini @@ -0,0 +1,7 @@ +# pytest.ini +[pytest] +env = + KEYCLOAK_ENDPOINT=keycloak-endpoint + KEYCLOAK_REALM=keycloak-realm + KEYCLOAK_API_CLIENT_ID=keycloak-api-client-id + KEYCLOAK_API_CLIENT_SECRET_KEY=keycloak-api-client-secret-key \ No newline at end of file diff --git a/services/api/tests/src/test_admin_intersection.py b/services/api/tests/src/test_admin_intersection.py deleted file mode 100644 index c1aefd0a3..000000000 --- a/services/api/tests/src/test_admin_intersection.py +++ /dev/null @@ -1,330 +0,0 @@ -from unittest.mock import patch, MagicMock, call -import pytest -import api.src.admin_intersection as admin_intersection -import api.tests.data.admin_intersection_data as admin_intersection_data -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from werkzeug.exceptions import HTTPException, BadRequest, InternalServerError -from api.tests.data import auth_data -from common.auth_tools import ENVIRON_USER_KEY - -user_valid = auth_data.get_request_environ() - - -###################################### Testing Requests ########################################## -# OPTIONS endpoint test -def test_request_options(): - info = admin_intersection.AdminIntersection() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,PATCH,DELETE" - - -# GET endpoint tests -@patch("api.src.admin_intersection.get_modify_intersection_data") -@patch( - "api.src.admin_intersection.request", - MagicMock( - args=admin_intersection_data.request_args_intersection_good, - ), -) -@patch( - "common.auth_tools.request", - MagicMock( - environ={ENVIRON_USER_KEY: user_valid}, - ), -) -def test_entry_get_intersection(mock_get_modify_intersection_data): - mock_get_modify_intersection_data.return_value = {} - status = admin_intersection.AdminIntersection() - (body, code, headers) = status.get() - - mock_get_modify_intersection_data.assert_called_once_with( - admin_intersection_data.request_args_intersection_good["intersection_id"], - user_valid, - ["Test Org", "Test Org 2", "Test Org 3"], - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch("api.src.admin_intersection.get_modify_intersection_data") -@patch( - "api.src.admin_intersection.request", - MagicMock( - args=admin_intersection_data.request_args_all_good, - ), -) -@patch( - "common.auth_tools.request", - MagicMock( - environ={ENVIRON_USER_KEY: user_valid}, - ), -) -def test_entry_get_all(mock_get_modify_intersection_data): - mock_get_modify_intersection_data.return_value = {} - status = admin_intersection.AdminIntersection() - (body, code, headers) = status.get() - - mock_get_modify_intersection_data.assert_called_once_with( - admin_intersection_data.request_args_all_good["intersection_id"], - user_valid, - ["Test Org", "Test Org 2", "Test Org 3"], - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -# Test schema for string value -@patch( - "api.src.admin_intersection.request", - MagicMock( - args=admin_intersection_data.request_args_str_bad, - ), -) -def test_entry_get_schema_str(): - status = admin_intersection.AdminIntersection() - with pytest.raises(HTTPException): - status.get() - - -# PATCH endpoint tests -@patch("api.src.admin_intersection.modify_intersection_authorized") -@patch( - "api.src.admin_intersection.request", - MagicMock( - json=admin_intersection_data.request_json_good, - ), -) -def test_entry_patch(mock_modify_intersection): - mock_modify_intersection.return_value = {} - status = admin_intersection.AdminIntersection() - (body, code, headers) = status.patch() - - mock_modify_intersection.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch( - "api.src.admin_intersection.request", - MagicMock( - json=admin_intersection_data.request_json_bad, - ), -) -def test_entry_patch_schema(): - status = admin_intersection.AdminIntersection() - with pytest.raises(HTTPException): - status.patch() - - -# DELETE endpoint tests -@patch("api.src.admin_intersection.delete_intersection_authorized") -@patch( - "api.src.admin_intersection.request", - MagicMock( - args=admin_intersection_data.request_args_intersection_good, - ), -) -def test_entry_delete_intersection(mock_delete_intersection): - mock_delete_intersection.return_value = {} - status = admin_intersection.AdminIntersection() - (body, code, headers) = status.delete() - - mock_delete_intersection.assert_called_once_with( - admin_intersection_data.request_args_intersection_good["intersection_id"], - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -###################################### Testing Functions ########################################## -# get_intersection_data -@patch("api.src.admin_intersection.pgquery.query_db") -def test_get_intersection_data_all(mock_query_db): - mock_query_db.return_value = admin_intersection_data.get_intersection_data_return - expected_intersection_data = admin_intersection_data.expected_get_intersection_all - expected_query = admin_intersection_data.expected_get_intersection_query_all - actual_result = admin_intersection.get_intersection_data("all", user_valid, []) - - mock_query_db.assert_called_with(expected_query, params={}) - assert actual_result == expected_intersection_data - - -@patch("api.src.admin_intersection.pgquery.query_db") -def test_get_intersection_data_intersection(mock_query_db): - mock_query_db.return_value = admin_intersection_data.get_intersection_data_return - expected_intersection_data = admin_intersection_data.expected_get_intersection_all[ - 0 - ] - expected_query = admin_intersection_data.expected_get_intersection_query_one - actual_result = admin_intersection.get_intersection_data("1123", user_valid, []) - - mock_query_db.assert_called_with( - expected_query, - params=admin_intersection_data.expected_get_intersection_query_one_params, - ) - assert actual_result == expected_intersection_data - - -@patch("api.src.admin_intersection.pgquery.query_db") -def test_get_intersection_data_none(mock_query_db): - # get Intersection should return an empty object if there are no Intersections with specified IP - mock_query_db.return_value = [] - expected_intersection_data = {} - expected_query = admin_intersection_data.expected_get_intersection_query_one - actual_result = admin_intersection.get_intersection_data("1123", user_valid, []) - - mock_query_db.assert_called_with( - expected_query, - params=admin_intersection_data.expected_get_intersection_query_one_params, - ) - assert actual_result == expected_intersection_data - - -# get_modify_intersection_data -@patch("api.src.admin_intersection.get_intersection_data") -def test_get_modify_intersection_data_all(mock_get_intersection_data): - mock_get_intersection_data.return_value = ["test intersection data"] - expected_intersection_data = {"intersection_data": ["test intersection data"]} - actual_result = admin_intersection.get_modify_intersection_data( - "all", user_valid, [] - ) - - assert actual_result == expected_intersection_data - - -@patch("api.src.admin_intersection.admin_new_intersection.get_allowed_selections") -@patch("api.src.admin_intersection.get_intersection_data") -def test_get_modify_intersection_data_intersection( - mock_get_intersection_data, mock_get_allowed_selections -): - mock_get_allowed_selections.return_value = "test selections" - mock_get_intersection_data.return_value = "test intersection data" - expected_intersection_data = { - "intersection_data": "test intersection data", - "allowed_selections": "test selections", - } - actual_result = admin_intersection.get_modify_intersection_data( - "1123", user_valid, [] - ) - - assert actual_result == expected_intersection_data - - -# modify_intersection -@patch("api.src.admin_intersection.admin_new_intersection.check_safe_input") -@patch("api.src.admin_intersection.pgquery.write_db") -def test_modify_intersection_success(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - expected_msg = {"message": "Intersection successfully modified"} - actual_msg = admin_intersection.modify_intersection_authorized( - intersection_id="1121", - intersection_spec=admin_intersection_data.request_json_good, - ) - - calls = [ - call( - admin_intersection_data.modify_intersection_sql[0], - params=admin_intersection_data.modify_intersection_sql[1], - ), - call( - admin_intersection_data.add_org_sql[0], - params=admin_intersection_data.add_org_sql[1], - ), - call( - admin_intersection_data.remove_org_sql[0], - params=admin_intersection_data.remove_org_sql[1], - ), - call( - admin_intersection_data.add_rsu_sql[0], - params=admin_intersection_data.add_rsu_sql[1], - ), - call( - admin_intersection_data.remove_rsu_sql[0], - params=admin_intersection_data.remove_rsu_sql[1], - ), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_intersection.admin_new_intersection.check_safe_input") -@patch("api.src.admin_intersection.pgquery.write_db") -def test_modify_intersection_check_fail(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_intersection.modify_intersection_authorized( - intersection_id="1121", - intersection_spec=admin_intersection_data.request_json_good, - ) - - assert ( - str(exc_info.value) - == "400 Bad Request: No special characters are allowed: !\"#$%'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_intersection.admin_new_intersection.check_safe_input") -@patch("api.src.admin_intersection.pgquery.write_db") -def test_modify_intersection_generic_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - mock_pgquery.side_effect = SQLAlchemyError("Test") - - with pytest.raises(InternalServerError) as exc_info: - admin_intersection.modify_intersection_authorized( - intersection_id="1121", - intersection_spec=admin_intersection_data.request_json_good, - ) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue executing query" - ) - - -@patch("api.src.admin_intersection.admin_new_intersection.check_safe_input") -@patch("api.src.admin_intersection.pgquery.write_db") -def test_modify_intersection_sql_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - orig = MagicMock() - orig.args = ({"D": "SQL issue encountered"},) - mock_pgquery.side_effect = IntegrityError("", {}, orig) - - with pytest.raises(InternalServerError) as exc_info: - admin_intersection.modify_intersection_authorized( - intersection_id="1121", - intersection_spec=admin_intersection_data.request_json_good, - ) - - assert str(exc_info.value) == "500 Internal Server Error: SQL issue encountered" - - -# delete_intersection -@patch("api.src.admin_intersection.pgquery.write_db") -def test_delete_intersection(mock_write_db): - expected_result = {"message": "Intersection successfully deleted"} - actual_result = admin_intersection.delete_intersection_authorized("1111") - - calls = [ - call( - admin_intersection_data.delete_intersection_calls[0][0], - params=admin_intersection_data.delete_intersection_calls[0][1], - ), - call( - admin_intersection_data.delete_intersection_calls[1][0], - params=admin_intersection_data.delete_intersection_calls[1][1], - ), - call( - admin_intersection_data.delete_intersection_calls[2][0], - params=admin_intersection_data.delete_intersection_calls[2][1], - ), - ] - mock_write_db.assert_has_calls(calls) - assert actual_result == expected_result diff --git a/services/api/tests/src/test_admin_new_intersection.py b/services/api/tests/src/test_admin_new_intersection.py deleted file mode 100644 index fcfe4ecc0..000000000 --- a/services/api/tests/src/test_admin_new_intersection.py +++ /dev/null @@ -1,73 +0,0 @@ -from unittest.mock import patch, MagicMock -import pytest -import api.src.admin_new_intersection as admin_new_intersection -import api.tests.data.admin_new_intersection_data as admin_new_intersection_data -from werkzeug.exceptions import HTTPException - - -# ##################################### Testing Requests ########################################## -def test_request_options(): - info = admin_new_intersection.AdminNewIntersection() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,POST" - - -@patch("api.src.admin_new_intersection.get_allowed_selections") -def test_entry_get(mock_get_allowed_selections): - mock_get_allowed_selections.return_value = {} - status = admin_new_intersection.AdminNewIntersection() - (body, code, headers) = status.get() - - mock_get_allowed_selections.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch("api.src.admin_new_intersection.add_intersection") -@patch( - "api.src.admin_new_intersection.request", - MagicMock( - json=admin_new_intersection_data.request_json_good, - ), -) -def test_entry_post(mock_add_intersection): - mock_add_intersection.return_value = {} - status = admin_new_intersection.AdminNewIntersection() - (body, code, headers) = status.post() - - mock_add_intersection.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch( - "api.src.admin_new_intersection.request", - MagicMock( - json=admin_new_intersection_data.request_json_bad, - ), -) -def test_entry_post_schema_bad_json(): - status = admin_new_intersection.AdminNewIntersection() - with pytest.raises(HTTPException): - status.post() - - -###################################### Testing Functions ########################################## -def test_check_safe_input(): - expected_result = True - actual_result = admin_new_intersection.check_safe_input( - admin_new_intersection_data.request_json_good - ) - assert actual_result == expected_result - - -def test_check_safe_input_bad(): - expected_result = False - actual_result = admin_new_intersection.check_safe_input( - admin_new_intersection_data.bad_input_check_safe_input - ) - assert actual_result == expected_result diff --git a/services/api/tests/src/test_admin_new_org.py b/services/api/tests/src/test_admin_new_org.py index 0b84a99d6..17f641a80 100644 --- a/services/api/tests/src/test_admin_new_org.py +++ b/services/api/tests/src/test_admin_new_org.py @@ -47,6 +47,18 @@ def test_entry_post_schema(): ###################################### Testing Functions ########################################## +def test_check_email(): + expected_result = True + actual_result = admin_new_org.check_email("jdoe@example.com") + assert actual_result == expected_result + + +def test_check_email_bad(): + expected_result = False + actual_result = admin_new_org.check_email("j--doe@exa@mple.com") + assert actual_result == expected_result + + def test_check_safe_input(): expected_result = True actual_result = admin_new_org.check_safe_input(admin_new_org_data.good_input) diff --git a/services/api/tests/src/test_admin_new_rsu.py b/services/api/tests/src/test_admin_new_rsu.py deleted file mode 100644 index 29dc4b03c..000000000 --- a/services/api/tests/src/test_admin_new_rsu.py +++ /dev/null @@ -1,195 +0,0 @@ -from unittest.mock import patch, MagicMock, call -import pytest -import api.src.admin_new_rsu as admin_new_rsu -import api.tests.data.admin_new_rsu_data as admin_new_rsu_data -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from werkzeug.exceptions import HTTPException -from api.tests.data import auth_data -from werkzeug.exceptions import BadRequest, InternalServerError - -user_valid = auth_data.get_request_environ() - - -# ##################################### Testing Requests ########################################## -def test_request_options(): - info = admin_new_rsu.AdminNewRsu() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,POST" - - -@patch("api.src.admin_new_rsu.get_allowed_selections") -@patch( - "api.src.admin_new_rsu.request", - MagicMock( - json=admin_new_rsu_data.request_json_good, - ), -) -def test_entry_get(mock_get_allowed_selections): - mock_get_allowed_selections.return_value = {} - status = admin_new_rsu.AdminNewRsu() - (body, code, headers) = status.get() - - mock_get_allowed_selections.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch("api.src.admin_new_rsu.add_rsu") -@patch( - "api.src.admin_new_rsu.request", - MagicMock( - json=admin_new_rsu_data.request_json_good, - ), -) -def test_entry_post(mock_add_rsu): - mock_add_rsu.return_value = {} - status = admin_new_rsu.AdminNewRsu() - (body, code, headers) = status.post() - - mock_add_rsu.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch( - "api.src.admin_new_rsu.request", - MagicMock( - json=admin_new_rsu_data.request_json_bad, - ), -) -def test_entry_post_schema(): - status = admin_new_rsu.AdminNewRsu() - with pytest.raises(HTTPException): - status.post() - - -###################################### Testing Functions ########################################## -@patch("common.pgquery.query_and_return_list") -def test_get_allowed_selections(mock_query_and_return_list): - mock_query_and_return_list.return_value = ["test"] - expected_result = { - "primary_routes": ["test"], - "rsu_models": ["test"], - "ssh_credential_groups": ["test"], - "snmp_credential_groups": ["test"], - "snmp_version_groups": ["test"], - "organizations": ["test"], - } - actual_result = admin_new_rsu.get_allowed_selections(user_valid) - - calls = [ - call( - "SELECT DISTINCT primary_route FROM public.rsus ORDER BY primary_route ASC" - ), - call( - "SELECT manufacturers.name as manufacturer, rsu_models.name as model FROM public.rsu_models JOIN public.manufacturers ON rsu_models.manufacturer = manufacturers.manufacturer_id ORDER BY manufacturer, model ASC" - ), - call("SELECT nickname FROM public.rsu_credentials ORDER BY nickname ASC"), - call("SELECT nickname FROM public.snmp_credentials ORDER BY nickname ASC"), - call("SELECT nickname FROM public.snmp_protocols ORDER BY nickname ASC"), - call("SELECT name FROM public.organizations ORDER BY name ASC"), - ] - mock_query_and_return_list.assert_has_calls(calls) - assert actual_result == expected_result - - -def test_check_safe_input(): - expected_result = True - actual_result = admin_new_rsu.check_safe_input(admin_new_rsu_data.good_input) - assert actual_result == expected_result - - -def test_check_safe_input_bad(): - expected_result = False - actual_result = admin_new_rsu.check_safe_input(admin_new_rsu_data.bad_input) - assert actual_result == expected_result - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_success_commsignia(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - expected_msg = {"message": "New RSU successfully added"} - actual_msg = admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_commsignia) - - calls = [ - call(admin_new_rsu_data.rsu_query_commsignia), - call(admin_new_rsu_data.rsu_org_query), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_success_yunex(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - expected_msg = {"message": "New RSU successfully added"} - actual_msg = admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_yunex) - - calls = [ - call(admin_new_rsu_data.rsu_query_yunex), - call(admin_new_rsu_data.rsu_org_query), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_safety_fail(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_commsignia) - - assert ( - str(exc_info.value) - == "400 Bad Request: No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_fail_yunex_no_scms_id(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - - with pytest.raises(BadRequest) as exc_info: - admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_yunex_no_scms) - - assert str(exc_info.value) == "400 Bad Request: SCMS ID must be specified" - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_generic_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - mock_pgquery.side_effect = SQLAlchemyError("Test") - - with pytest.raises(InternalServerError) as exc_info: - admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_commsignia) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue executing query" - ) - - -@patch("api.src.admin_new_rsu.check_safe_input") -@patch("api.src.admin_new_rsu.pgquery.write_db") -def test_add_rsu_sql_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - orig = MagicMock() - orig.args = ({"D": "SQL issue encountered"},) - mock_pgquery.side_effect = IntegrityError("", {}, orig) - - with pytest.raises(InternalServerError) as exc_info: - admin_new_rsu.add_rsu(admin_new_rsu_data.mock_post_body_commsignia) - - assert str(exc_info.value) == "500 Internal Server Error: SQL issue encountered" diff --git a/services/api/tests/src/test_admin_new_user.py b/services/api/tests/src/test_admin_new_user.py deleted file mode 100644 index 9e7d234d1..000000000 --- a/services/api/tests/src/test_admin_new_user.py +++ /dev/null @@ -1,179 +0,0 @@ -from unittest.mock import patch, MagicMock, call -import pytest -import api.src.admin_new_user as admin_new_user -import api.tests.data.admin_new_user_data as admin_new_user_data -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from werkzeug.exceptions import HTTPException -from api.tests.data import auth_data -from werkzeug.exceptions import BadRequest, InternalServerError - -user_valid = auth_data.get_request_environ() - - -###################################### Testing Requests ########################################## -def test_request_options(): - info = admin_new_user.AdminNewUser() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,POST" - - -@patch("api.src.admin_new_user.get_allowed_selections") -def test_entry_get(mock_get_allowed_selections): - req = MagicMock() - mock_get_allowed_selections.return_value = {} - with patch("api.src.admin_new_user.request", req): - status = admin_new_user.AdminNewUser() - (body, code, headers) = status.get() - - mock_get_allowed_selections.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch("api.src.admin_new_user.add_user") -def test_entry_post(mock_add_user): - req = MagicMock() - req.json = admin_new_user_data.request_json_good - mock_add_user.return_value = {} - with patch("api.src.admin_new_user.request", req): - status = admin_new_user.AdminNewUser() - (body, code, headers) = status.post() - - mock_add_user.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -def test_entry_post_schema(): - req = MagicMock() - req.json = admin_new_user_data.request_json_bad - with patch("api.src.admin_new_user.request", req): - status = admin_new_user.AdminNewUser() - with pytest.raises(HTTPException): - status.post() - - -###################################### Testing Functions ########################################## -@patch("common.pgquery.query_and_return_list") -def test_get_allowed_selections(mock_query_and_return_list): - mock_query_and_return_list.return_value = ["test"] - expected_result = {"organizations": ["test"], "roles": ["test"]} - actual_result = admin_new_user.get_allowed_selections(user_valid) - calls = [ - call("SELECT name FROM public.organizations ORDER BY name ASC"), - call("SELECT name FROM public.roles ORDER BY name"), - ] - mock_query_and_return_list.assert_has_calls(calls) - assert actual_result == expected_result - - -def test_check_email(): - expected_result = True - actual_result = admin_new_user.check_email(admin_new_user_data.good_input["email"]) - assert actual_result == expected_result - - -def test_check_email_bad(): - expected_result = False - actual_result = admin_new_user.check_email(admin_new_user_data.bad_input["email"]) - assert actual_result == expected_result - - -def test_check_safe_input(): - expected_result = True - actual_result = admin_new_user.check_safe_input(admin_new_user_data.good_input) - assert actual_result == expected_result - - -def test_check_safe_input_bad(): - expected_result = False - actual_result = admin_new_user.check_safe_input(admin_new_user_data.bad_input) - assert actual_result == expected_result - - -@patch("time.time") # Mock time.time -@patch("api.src.admin_new_user.check_safe_input") -@patch("api.src.admin_new_user.check_email") -@patch("api.src.admin_new_user.pgquery.write_db") -def test_add_user_success( - mock_pgquery, mock_check_email, mock_check_safe_input, mock_time -): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - mock_time.return_value = 1678901234 # Mocked Unix time in seconds - expected_msg = {"message": "New user successfully added"} - actual_msg = admin_new_user.add_user(admin_new_user_data.request_json_good) - - calls = [ - call(admin_new_user_data.user_insert_query), - call(admin_new_user_data.user_org_insert_query), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_new_user.check_email") -@patch("api.src.admin_new_user.pgquery.write_db") -def test_add_user_email_fail(mock_pgquery, mock_check_email): - mock_check_email.return_value = False - with pytest.raises(BadRequest) as exc_info: - admin_new_user.add_user(admin_new_user_data.request_json_good) - - assert str(exc_info.value) == "400 Bad Request: Email is not valid" - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_new_user.check_safe_input") -@patch("api.src.admin_new_user.check_email") -@patch("api.src.admin_new_user.pgquery.write_db") -def test_add_user_check_fail(mock_pgquery, mock_check_email, mock_check_safe_input): - mock_check_email.return_value = True - mock_check_safe_input.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_new_user.add_user(admin_new_user_data.request_json_good) - - assert ( - str(exc_info.value) - == "400 Bad Request: No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_new_user.check_safe_input") -@patch("api.src.admin_new_user.check_email") -@patch("api.src.admin_new_user.pgquery.write_db") -def test_add_user_generic_exception( - mock_pgquery, mock_check_email, mock_check_safe_input -): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - mock_pgquery.side_effect = SQLAlchemyError("Test") - - with pytest.raises(InternalServerError) as exc_info: - admin_new_user.add_user(admin_new_user_data.request_json_good) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue executing query" - ) - - -@patch("api.src.admin_new_user.check_safe_input") -@patch("api.src.admin_new_user.check_email") -@patch("api.src.admin_new_user.pgquery.write_db") -def test_add_user_sql_exception(mock_pgquery, mock_check_email, mock_check_safe_input): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - orig = MagicMock() - orig.args = ({"D": "SQL issue encountered"},) - mock_pgquery.side_effect = IntegrityError("", {}, orig) - - with pytest.raises(InternalServerError) as exc_info: - admin_new_user.add_user(admin_new_user_data.request_json_good) - - assert str(exc_info.value) == "500 Internal Server Error: SQL issue encountered" diff --git a/services/api/tests/src/test_admin_org.py b/services/api/tests/src/test_admin_org.py index e6e18d37e..1c52105f4 100644 --- a/services/api/tests/src/test_admin_org.py +++ b/services/api/tests/src/test_admin_org.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock, call, PropertyMock import pytest import api.src.admin_org as admin_org import api.tests.data.admin_org_data as admin_org_data @@ -6,10 +6,34 @@ from werkzeug.exceptions import HTTPException from api.tests.data import auth_data from werkzeug.exceptions import BadRequest, Conflict, InternalServerError +from flask import Flask, request +from common.auth_tools import ( + ENVIRON_USER_KEY, + EnvironWithOrg, + ORG_ROLE_LITERAL, + UserInfo, +) user_valid = auth_data.get_request_environ() +@pytest.fixture +def app(): + app = Flask(__name__) + return app + + +@pytest.fixture +def permission_result(): + mock_user_info = MagicMock(spec=UserInfo) + mock_user_info.super_user = True + mock_user_info.organizations = {"Test Org": "admin"} + + user = EnvironWithOrg(mock_user_info, "Test Org", ORG_ROLE_LITERAL.ADMIN) + + return user + + # ##################################### Testing Requests ########################################## # OPTIONS endpoint test def test_request_options(): @@ -22,27 +46,23 @@ def test_request_options(): # GET endpoint tests @patch("api.src.admin_org.get_modify_org_data_authorized") -def test_entry_get(mock_get_modify_org_data): - req = MagicMock() - req.args = admin_org_data.request_args_get_delete_good +def test_entry_get(mock_get_modify_org_data, app): mock_get_modify_org_data.return_value = {} - with patch("api.src.admin_org.request", req): + with app.test_request_context(query_string=admin_org_data.request_args_get_delete_good): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() (body, code, headers) = status.get() - mock_get_modify_org_data.assert_called_once_with( - admin_org_data.request_args_get_delete_good["org_name"] - ) + mock_get_modify_org_data.assert_called_once() assert code == 200 assert headers["Access-Control-Allow-Origin"] == "test.com" assert body == {} # Test schema for string value -def test_entry_get_schema_str(): - req = MagicMock() - req.args = admin_org_data.request_args_get_delete_bad - with patch("api.src.admin_org.request", req): +def test_entry_get_schema_str(app): + with app.test_request_context(query_string=admin_org_data.request_args_get_delete_bad): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() with pytest.raises(HTTPException): status.get() @@ -50,24 +70,26 @@ def test_entry_get_schema_str(): # PATCH endpoint tests @patch("api.src.admin_org.modify_org_authorized") -def test_entry_patch(mock_modify_org): - req = MagicMock() - req.json = admin_org_data.request_json_good +def test_entry_patch(mock_modify_org, app): mock_modify_org.return_value = {} - with patch("api.src.admin_org.request", req): + with app.test_request_context(json=admin_org_data.request_json_good): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() (body, code, headers) = status.patch() - mock_modify_org.assert_called_once() + mock_modify_org.assert_called_once_with( + admin_org_data.request_json_good["orig_name"], + admin_org_data.request_json_good, + is_bulk_update=False, + ) assert code == 200 assert headers["Access-Control-Allow-Origin"] == "test.com" assert body == {} -def test_entry_patch_schema(): - req = MagicMock() - req.json = admin_org_data.request_json_bad - with patch("api.src.admin_org.request", req): +def test_entry_patch_schema(app): + with app.test_request_context(json=admin_org_data.request_json_bad): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() with pytest.raises(HTTPException): status.patch() @@ -75,26 +97,22 @@ def test_entry_patch_schema(): # DELETE endpoint tests @patch("api.src.admin_org.delete_org_authorized") -def test_entry_delete_user(mock_delete_org): - req = MagicMock() - req.args = admin_org_data.request_args_get_delete_good +def test_entry_delete_user(mock_delete_org, app): mock_delete_org.return_value = {"message": "Organization successfully deleted"} - with patch("api.src.admin_org.request", req): + with app.test_request_context(query_string=admin_org_data.request_args_get_delete_good): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() (body, code, headers) = status.delete() - mock_delete_org.assert_called_once_with( - admin_org_data.request_args_get_delete_good["org_name"] - ) + mock_delete_org.assert_called_once() assert code == 200 assert headers["Access-Control-Allow-Origin"] == "test.com" assert body == {"message": "Organization successfully deleted"} -def test_entry_delete_schema(): - req = MagicMock() - req.args = admin_org_data.request_args_get_delete_bad - with patch("api.src.admin_org.request", req): +def test_entry_delete_schema(app): + with app.test_request_context(query_string={}): + request.environ[ENVIRON_USER_KEY] = user_valid status = admin_org.AdminOrg() with pytest.raises(HTTPException): status.delete() @@ -152,12 +170,16 @@ def test_get_allowed_selections(mock_query_db): # get_modify_org_data @patch("api.src.admin_org.get_all_orgs") -def test_get_modify_org_data_all(mock_get_all_orgs): +def test_get_modify_org_data_all(mock_get_all_orgs, app): mock_get_all_orgs.return_value = ["Test Org data"] expected_rsu_data = {"org_data": ["Test Org data"]} - actual_result = admin_org.get_modify_org_data_authorized( - "all", - ) + mock_permission_result = MagicMock() + mock_permission_result.user.user_info.super_user = True + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + actual_result = admin_org.get_modify_org_data_authorized( + "all", permission_result=mock_permission_result + ) mock_get_all_orgs.assert_called_with(None) assert actual_result == expected_rsu_data @@ -165,16 +187,22 @@ def test_get_modify_org_data_all(mock_get_all_orgs): @patch("api.src.admin_org.get_allowed_selections") @patch("api.src.admin_org.get_org_data") -def test_get_modify_org_data_specific(mock_get_org_data, mock_get_allowed_selections): +def test_get_modify_org_data_specific( + mock_get_org_data, mock_get_allowed_selections, app +): mock_get_org_data.return_value = "Test Org data" mock_get_allowed_selections.return_value = ["allowed_selections"] expected_rsu_data = { "org_data": "Test Org data", "allowed_selections": ["allowed_selections"], } - actual_result = admin_org.get_modify_org_data_authorized( - "Test Org", - ) + mock_permission_result = MagicMock() + mock_permission_result.user.user_info.super_user = True + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + actual_result = admin_org.get_modify_org_data_authorized( + "Test Org", permission_result=mock_permission_result + ) mock_get_org_data.assert_called_with("Test Org", True) mock_get_allowed_selections.assert_called_with() @@ -195,14 +223,20 @@ def test_check_safe_input_bad(): # modify_org +@patch("api.src.admin_org.get_modify_org_data_authorized") @patch("api.src.admin_org.check_safe_input") @patch("api.src.admin_org.pgquery.write_db") -def test_modify_organization_success(mock_pgquery, mock_check_safe_input): +def test_modify_organization_success( + mock_pgquery, mock_check_safe_input, mock_get_modify_org_data, app +): mock_check_safe_input.return_value = True - expected_msg = {"message": "Organization successfully modified"} - actual_msg = admin_org.modify_org_authorized( - "Test Org", admin_org_data.request_json_good - ) + mock_get_modify_org_data.return_value = {"org_data": "mocked_data"} + expected_msg = {"org_data": "mocked_data"} + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + actual_msg = admin_org.modify_org_authorized( + "Test Org", admin_org_data.request_json_good, is_bulk_update=False + ) calls = [ call(admin_org_data.modify_org_sql[0], params=admin_org_data.modify_org_sql[1]), @@ -241,12 +275,14 @@ def test_modify_organization_success(mock_pgquery, mock_check_safe_input): @patch("api.src.admin_org.check_safe_input") @patch("api.src.admin_org.pgquery.write_db") -def test_modify_org_check_fail(mock_pgquery, mock_check_safe_input): +def test_modify_org_check_fail(mock_pgquery, mock_check_safe_input, app): mock_check_safe_input.return_value = False expected_message = "400 Bad Request: No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - with pytest.raises(BadRequest) as exc_info: - admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(BadRequest) as exc_info: + admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) mock_pgquery.assert_has_calls([]) assert str(exc_info.value) == expected_message @@ -254,30 +290,34 @@ def test_modify_org_check_fail(mock_pgquery, mock_check_safe_input): @patch("api.src.admin_org.check_safe_input") @patch("api.src.admin_org.pgquery.write_db") -def test_modify_org_generic_exception(mock_pgquery, mock_check_safe_input): +def test_modify_org_generic_exception(mock_pgquery, mock_check_safe_input, app): mock_check_safe_input.return_value = True mock_pgquery.side_effect = SQLAlchemyError("Test") expected_message = ( "500 Internal Server Error: Encountered unknown issue executing query" ) - with pytest.raises(InternalServerError) as exc_info: - admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(InternalServerError) as exc_info: + admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) assert str(exc_info.value) == expected_message @patch("api.src.admin_org.check_safe_input") @patch("api.src.admin_org.pgquery.write_db") -def test_modify_org_sql_exception(mock_pgquery, mock_check_safe_input): +def test_modify_org_sql_exception(mock_pgquery, mock_check_safe_input, app): mock_check_safe_input.return_value = True orig = MagicMock() orig.args = ({"D": "SQL issue encountered"},) mock_pgquery.side_effect = IntegrityError("", {}, orig) expected_message = "500 Internal Server Error: SQL issue encountered" - with pytest.raises(InternalServerError) as exc_info: - admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(InternalServerError) as exc_info: + admin_org.modify_org_authorized("Test Org", admin_org_data.request_json_good) assert str(exc_info.value) == expected_message @@ -285,10 +325,12 @@ def test_modify_org_sql_exception(mock_pgquery, mock_check_safe_input): # delete_org @patch("api.src.admin_org.pgquery.write_db") @patch("api.src.admin_org.pgquery.query_db") -def test_delete_org(mock_query_db, mock_write_db): +def test_delete_org(mock_query_db, mock_write_db, app): mock_query_db.return_value = [] expected_result = {"message": "Organization successfully deleted"} - actual_result = admin_org.delete_org_authorized("Test Org") + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + actual_result = admin_org.delete_org_authorized("Test Org") calls = [ call( @@ -309,14 +351,16 @@ def test_delete_org(mock_query_db, mock_write_db): @patch("api.src.admin_org.pgquery.query_db") -def test_delete_org_failure_orphan_rsu(mock_query_db): +def test_delete_org_failure_orphan_rsu(mock_query_db, app): mock_query_db.return_value = [ [{"user_id": 1, "count": 2}], [{"user_id": 2, "count": 1}], ] expected_message = "409 Conflict: Cannot delete organization that has one or more RSUs only associated with this organization" - with pytest.raises(Conflict) as exc_info: - admin_org.delete_org_authorized("Test Org") + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(Conflict) as exc_info: + admin_org.delete_org_authorized("Test Org") assert str(exc_info.value) == expected_message @@ -325,7 +369,7 @@ def test_delete_org_failure_orphan_rsu(mock_query_db): @patch("api.src.admin_org.check_orphan_rsus") @patch("api.src.admin_org.check_orphan_intersections") def test_delete_org_failure_orphan_user( - mock_orphan_intersections, mock_orphan_rsus, mock_query_db + mock_orphan_intersections, mock_orphan_rsus, mock_query_db, app ): mock_orphan_intersections.return_value = False mock_orphan_rsus.return_value = False @@ -334,22 +378,256 @@ def test_delete_org_failure_orphan_user( [{"user_id": 2, "count": 1}], ] expected_message = "409 Conflict: Cannot delete organization that has one or more users only associated with this organization" - with pytest.raises(Conflict) as exc_info: - admin_org.delete_org_authorized("Test Org") + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(Conflict) as exc_info: + admin_org.delete_org_authorized("Test Org") assert str(exc_info.value) == expected_message @patch("api.src.admin_org.pgquery.query_db") @patch("api.src.admin_org.check_orphan_rsus") -def test_delete_org_failure_orphan_intersection(mock_orphan_rsus, mock_query_db): +def test_delete_org_failure_orphan_intersection(mock_orphan_rsus, mock_query_db, app): mock_orphan_rsus.return_value = False mock_query_db.return_value = [ [{"user_id": 1, "count": 2}], [{"user_id": 2, "count": 1}], ] expected_message = "409 Conflict: Cannot delete organization that has one or more Intersections only associated with this organization" - with pytest.raises(Conflict) as exc_info: - admin_org.delete_org_authorized("Test Org") + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = user_valid + with pytest.raises(Conflict) as exc_info: + admin_org.delete_org_authorized("Test Org") assert str(exc_info.value) == expected_message + + +# ##################################### Bulk Update Tests ######################################### + + +def test_modify_org_bulk_tim_deposit_success(app, permission_result): + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "tim_deposit": True, + } + + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.pgquery.write_db") as mock_write_db, patch( + "api.src.admin_org.check_safe_input", return_value=True + ), patch( + "api.src.admin_org.get_modify_org_data_authorized", + return_value={"org_data": "mocked_data"}, + ): + + result = admin_org.modify_org_authorized( + "Test Org", org_spec, is_bulk_update=True + ) + + assert result == {"org_data": "mocked_data"} + + # Should have 2 write_db calls: 1 for org update, 1 for bulk RSU options update + assert mock_write_db.call_count == 2 + + # Check bulk update call + bulk_query = ( + "INSERT INTO public.rsu_options (rsu_id, tim_deposit) " + "SELECT ro.rsu_id, :tim_deposit " + "FROM public.rsu_organization ro " + "JOIN public.organizations org ON ro.organization_id = org.organization_id " + "WHERE org.name = :name " + "ON CONFLICT (rsu_id) DO UPDATE SET tim_deposit = EXCLUDED.tim_deposit" + ) + bulk_params = {"name": "Test Org", "tim_deposit": True} + + mock_write_db.assert_any_call(bulk_query, params=bulk_params) + + +def test_modify_org_bulk_snmp_monitoring_success(app, permission_result): + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "snmp_monitoring": True, + } + + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.pgquery.write_db") as mock_write_db, patch( + "api.src.admin_org.check_safe_input", return_value=True + ), patch( + "api.src.admin_org.get_modify_org_data_authorized", + return_value={"org_data": "mocked_data"}, + ): + + result = admin_org.modify_org_authorized( + "Test Org", org_spec, is_bulk_update=True + ) + + assert result == {"org_data": "mocked_data"} + + # Check bulk update call + bulk_query = ( + "INSERT INTO public.rsu_options (rsu_id, snmp_monitoring) " + "SELECT ro.rsu_id, :snmp_monitoring " + "FROM public.rsu_organization ro " + "JOIN public.organizations org ON ro.organization_id = org.organization_id " + "WHERE org.name = :name " + "ON CONFLICT (rsu_id) DO UPDATE SET snmp_monitoring = EXCLUDED.snmp_monitoring" + ) + bulk_params = {"name": "Test Org", "snmp_monitoring": True} + + mock_write_db.assert_any_call(bulk_query, params=bulk_params) + + +def test_modify_org_bulk_both_success(app, permission_result): + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "tim_deposit": True, + "snmp_monitoring": False, + } + + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.pgquery.write_db") as mock_write_db, patch( + "api.src.admin_org.check_safe_input", return_value=True + ), patch( + "api.src.admin_org.get_modify_org_data_authorized", + return_value={"org_data": "mocked_data"}, + ): + + result = admin_org.modify_org_authorized( + "Test Org", org_spec, is_bulk_update=True + ) + + assert result == {"org_data": "mocked_data"} + + # Check bulk update call + bulk_query = ( + "INSERT INTO public.rsu_options (rsu_id, tim_deposit, snmp_monitoring) " + "SELECT ro.rsu_id, :tim_deposit, :snmp_monitoring " + "FROM public.rsu_organization ro " + "JOIN public.organizations org ON ro.organization_id = org.organization_id " + "WHERE org.name = :name " + "ON CONFLICT (rsu_id) DO UPDATE SET tim_deposit = EXCLUDED.tim_deposit, snmp_monitoring = EXCLUDED.snmp_monitoring" + ) + bulk_params = { + "name": "Test Org", + "tim_deposit": True, + "snmp_monitoring": False, + } + + mock_write_db.assert_any_call(bulk_query, params=bulk_params) + + +def test_modify_org_no_bulk_endpoint(app, permission_result): + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "tim_deposit": True, + } + + with app.test_request_context(): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.pgquery.write_db") as mock_write_db, patch( + "api.src.admin_org.check_safe_input", return_value=True + ), patch( + "api.src.admin_org.get_modify_org_data_authorized", + return_value={"org_data": "mocked_data"}, + ): + + result = admin_org.modify_org_authorized( + "Test Org", org_spec, is_bulk_update=False + ) + + assert result == {"org_data": "mocked_data"} + # Should only have 1 call (for org update) + assert mock_write_db.call_count == 1 + assert ( + "INSERT INTO public.rsu_options" + not in mock_write_db.call_args_list[0][0][0] + ) + + +def test_admin_org_tim_deposit_patch(app, permission_result): + resource = admin_org.AdminOrgTimDeposit() + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "tim_deposit": True, + } + with app.test_request_context(json=org_spec): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.modify_org_authorized") as mock_modify: + mock_modify.return_value = {"message": "success"} + (body, code, headers) = resource.patch() + assert code == 200 + assert body == {"message": "success"} + mock_modify.assert_called_once_with("Test Org", org_spec, is_bulk_update=True) + + +def test_admin_org_snmp_monitoring_patch(app, permission_result): + resource = admin_org.AdminOrgSnmpMonitoring() + org_spec = { + "orig_name": "Test Org", + "name": "Test Org", + "email": "test@gmail.com", + "users_to_add": [], + "users_to_modify": [], + "users_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [], + "intersections_to_add": [], + "intersections_to_remove": [], + "snmp_monitoring": True, + } + with app.test_request_context(json=org_spec): + request.environ[ENVIRON_USER_KEY] = permission_result + with patch("api.src.admin_org.modify_org_authorized") as mock_modify: + mock_modify.return_value = {"message": "success"} + (body, code, headers) = resource.patch() + assert code == 200 + assert body == {"message": "success"} + mock_modify.assert_called_once_with("Test Org", org_spec, is_bulk_update=True) diff --git a/services/api/tests/src/test_admin_rsu.py b/services/api/tests/src/test_admin_rsu.py deleted file mode 100644 index ce178e691..000000000 --- a/services/api/tests/src/test_admin_rsu.py +++ /dev/null @@ -1,290 +0,0 @@ -from unittest.mock import patch, MagicMock, call -import pytest -import api.src.admin_rsu as admin_rsu -import api.tests.data.admin_rsu_data as admin_rsu_data -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from werkzeug.exceptions import HTTPException -from api.tests.data import auth_data -from werkzeug.exceptions import BadRequest, InternalServerError - -user_valid = auth_data.get_request_environ() - - -# ##################################### Testing Requests ########################################## -# OPTIONS endpoint test -def test_request_options(): - info = admin_rsu.AdminRsu() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,PATCH,DELETE" - - -# GET endpoint tests -@patch("api.src.admin_rsu.get_modify_rsu_data_authorized") -def test_entry_get_rsu(mock_get_modify_rsu_data): - req = MagicMock() - req.args = admin_rsu_data.request_args_rsu_good - mock_get_modify_rsu_data.return_value = {} - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - (body, code, headers) = status.get() - - mock_get_modify_rsu_data.assert_called_once_with( - admin_rsu_data.request_args_rsu_good["rsu_ip"] - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch("api.src.admin_rsu.get_modify_rsu_data_authorized") -def test_entry_get_all(mock_get_modify_rsu_data): - req = MagicMock() - req.args = admin_rsu_data.request_args_all_good - mock_get_modify_rsu_data.return_value = {} - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - (body, code, headers) = status.get() - - mock_get_modify_rsu_data.assert_called_once_with( - admin_rsu_data.request_args_all_good["rsu_ip"] - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -# Test schema for string value -def test_entry_get_schema_str(): - req = MagicMock() - req.json = admin_rsu_data.request_args_str_bad - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - with pytest.raises(HTTPException): - status.get() - - -# Test schema for IPv4 string if not "all" -def test_entry_get_schema_ipv4(): - req = MagicMock() - req.json = admin_rsu_data.request_args_ipv4_bad - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - with pytest.raises(HTTPException): - status.get() - - -# PATCH endpoint tests -@patch("api.src.admin_rsu.modify_rsu_authorized") -def test_entry_patch(mock_modify_rsu): - req = MagicMock() - req.json = admin_rsu_data.request_json_good - mock_modify_rsu.return_value = {} - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - (body, code, headers) = status.patch() - - mock_modify_rsu.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -def test_entry_patch_schema(): - req = MagicMock() - req.json = admin_rsu_data.request_json_bad - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - with pytest.raises(HTTPException): - status.patch() - - -# DELETE endpoint tests -@patch("api.src.admin_rsu.delete_rsu_authorized") -def test_entry_delete_rsu(mock_delete_rsu): - req = MagicMock() - req.args = admin_rsu_data.request_args_rsu_good - mock_delete_rsu.return_value = {} - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - (body, code, headers) = status.delete() - - mock_delete_rsu.assert_called_once_with( - admin_rsu_data.request_args_rsu_good["rsu_ip"] - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -# Check single schema that requires IPv4 string -def test_entry_delete_schema(): - req = MagicMock() - req.json = admin_rsu_data.request_args_ipv4_bad - with patch("api.src.admin_rsu.request", req): - status = admin_rsu.AdminRsu() - with pytest.raises(HTTPException): - status.delete() - - -###################################### Testing Functions ########################################## -@patch("api.src.admin_rsu.pgquery.query_db") -def test_get_rsu_data_all(mock_query_db): - mock_query_db.return_value = admin_rsu_data.get_rsu_data_return - expected_rsu_data = admin_rsu_data.expected_get_rsu_all - expected_query = admin_rsu_data.expected_get_rsu_query_all - actual_result = admin_rsu.get_rsu_data("all", user_valid, qualified_orgs=[]) - - mock_query_db.assert_called_with(expected_query, params={}) - assert actual_result == expected_rsu_data - - -@patch("api.src.admin_rsu.pgquery.query_db") -def test_get_rsu_data_rsu(mock_query_db): - mock_query_db.return_value = admin_rsu_data.get_rsu_data_return - expected_rsu_data = admin_rsu_data.expected_get_rsu_all[0] - expected_query = admin_rsu_data.expected_get_rsu_query_one - actual_result = admin_rsu.get_rsu_data("10.11.81.12", user_valid, qualified_orgs=[]) - - mock_query_db.assert_called_with(expected_query, params={"rsu_ip": "10.11.81.12"}) - assert actual_result == expected_rsu_data - - -@patch("api.src.admin_rsu.pgquery.query_db") -def test_get_rsu_data_none(mock_query_db): - # get RSU should return an empty object if there are no RSUs with specified IP - mock_query_db.return_value = [] - expected_rsu_data = {} - expected_query = admin_rsu_data.expected_get_rsu_query_one - actual_result = admin_rsu.get_rsu_data("10.11.81.12", user_valid, qualified_orgs=[]) - - mock_query_db.assert_called_with(expected_query, params={"rsu_ip": "10.11.81.12"}) - assert actual_result == expected_rsu_data - - -# get_modify_rsu_data -@patch("api.src.admin_rsu.get_rsu_data") -def test_get_modify_rsu_data_all(mock_get_rsu_data): - mock_get_rsu_data.return_value = ["test rsu data"] - expected_rsu_data = {"rsu_data": ["test rsu data"]} - actual_result = admin_rsu.get_modify_rsu_data_authorized( - "all", - ) - - assert actual_result == expected_rsu_data - - -@patch("api.src.admin_rsu.admin_new_rsu.get_allowed_selections") -@patch("api.src.admin_rsu.get_rsu_data") -def test_get_modify_rsu_data_rsu(mock_get_rsu_data, mock_get_allowed_selections): - mock_get_allowed_selections.return_value = "test selections" - mock_get_rsu_data.return_value = "test rsu data" - expected_rsu_data = { - "rsu_data": "test rsu data", - "allowed_selections": "test selections", - } - actual_result = admin_rsu.get_modify_rsu_data_authorized("10.11.81.13") - - assert actual_result == expected_rsu_data - - -# modify_rsu -@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") -@patch("api.src.admin_rsu.pgquery.write_db") -def test_modify_rsu_success(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - expected_msg = {"message": "RSU successfully modified"} - actual_msg = admin_rsu.modify_rsu_authorized( - orig_ip="10.0.0.1", rsu_spec=admin_rsu_data.request_json_good - ) - - calls = [ - call(admin_rsu_data.modify_rsu_sql[0], params=admin_rsu_data.modify_rsu_sql[1]), - call(admin_rsu_data.add_org_sql[0], params=admin_rsu_data.add_org_sql[1]), - call(admin_rsu_data.remove_org_sql[0], params=admin_rsu_data.remove_org_sql[1]), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") -@patch("api.src.admin_rsu.pgquery.write_db") -def test_modify_rsu_check_fail(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_rsu.modify_rsu_authorized( - orig_ip="10.0.0.1", rsu_spec=admin_rsu_data.request_json_good - ) - - assert ( - str(exc_info.value) - == "400 Bad Request: No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") -@patch("api.src.admin_rsu.pgquery.write_db") -def test_modify_rsu_generic_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - mock_pgquery.side_effect = SQLAlchemyError("Test") - - with pytest.raises(InternalServerError) as exc_info: - admin_rsu.modify_rsu_authorized( - orig_ip="10.0.0.1", rsu_spec=admin_rsu_data.request_json_good - ) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue executing query" - ) - - -@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") -@patch("api.src.admin_rsu.pgquery.write_db") -def test_modify_rsu_sql_exception(mock_pgquery, mock_check_safe_input): - mock_check_safe_input.return_value = True - orig = MagicMock() - orig.args = ({"D": "SQL issue encountered"},) - mock_pgquery.side_effect = IntegrityError("", {}, orig) - - with pytest.raises(InternalServerError) as exc_info: - admin_rsu.modify_rsu_authorized( - orig_ip="10.0.0.1", rsu_spec=admin_rsu_data.request_json_good - ) - - assert str(exc_info.value) == "500 Internal Server Error: SQL issue encountered" - - -# delete_rsu -@patch("api.src.admin_rsu.pgquery.write_db") -def test_delete_rsu(mock_write_db): - expected_result = {"message": "RSU successfully deleted"} - actual_result = admin_rsu.delete_rsu_authorized("10.11.81.12") - - calls = [ - call( - admin_rsu_data.delete_rsu_calls[0][0], - params=admin_rsu_data.delete_rsu_calls[0][1], - ), - call( - admin_rsu_data.delete_rsu_calls[1][0], - params=admin_rsu_data.delete_rsu_calls[1][1], - ), - call( - admin_rsu_data.delete_rsu_calls[2][0], - params=admin_rsu_data.delete_rsu_calls[2][1], - ), - call( - admin_rsu_data.delete_rsu_calls[3][0], - params=admin_rsu_data.delete_rsu_calls[3][1], - ), - call( - admin_rsu_data.delete_rsu_calls[4][0], - params=admin_rsu_data.delete_rsu_calls[4][1], - ), - ] - mock_write_db.assert_has_calls(calls) - assert actual_result == expected_result diff --git a/services/api/tests/src/test_admin_user.py b/services/api/tests/src/test_admin_user.py deleted file mode 100644 index 97e114ef7..000000000 --- a/services/api/tests/src/test_admin_user.py +++ /dev/null @@ -1,310 +0,0 @@ -from unittest.mock import patch, MagicMock, call -import pytest -import api.src.admin_user as admin_user -import api.tests.data.admin_user_data as admin_user_data -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from api.tests.data import auth_data -from werkzeug.exceptions import BadRequest, HTTPException, InternalServerError - -user_valid = auth_data.get_request_environ() - - -# ##################################### Testing Requests ########################################## -# OPTIONS endpoint test -def test_request_options(): - info = admin_user.AdminUser() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET,PATCH,DELETE" - - -# GET endpoint tests -@patch("api.src.admin_user.get_modify_user_data_authorized") -@patch( - "api.src.admin_user.request", - MagicMock( - args=admin_user_data.request_args_good, - ), -) -def test_entry_get(mock_get_modify_user_data): - mock_get_modify_user_data.return_value = {} - status = admin_user.AdminUser() - (body, code, headers) = status.get() - - mock_get_modify_user_data.assert_called_once_with( - admin_user_data.request_args_good["user_email"] - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -# Test schema for string value -@patch( - "api.src.admin_user.request", - MagicMock( - json=admin_user_data.request_args_bad, - ), -) -def test_entry_get_schema_str(): - status = admin_user.AdminUser() - with pytest.raises(HTTPException): - status.get() - - -# PATCH endpoint tests -@patch("api.src.admin_user.modify_user") -@patch( - "api.src.admin_user.request", - MagicMock( - json=admin_user_data.request_json_good, - ), -) -def test_entry_patch(mock_modify_user): - mock_modify_user.return_value = {} - status = admin_user.AdminUser() - (body, code, headers) = status.patch() - - mock_modify_user.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch( - "api.src.admin_user.request", - MagicMock( - json=admin_user_data.request_json_bad, - ), -) -def test_entry_patch_schema(): - status = admin_user.AdminUser() - with pytest.raises(HTTPException): - status.patch() - - -# DELETE endpoint tests -@patch("api.src.admin_user.delete_user_authorized") -@patch( - "api.src.admin_user.request", - MagicMock( - args=admin_user_data.request_args_good, - ), -) -def test_entry_delete_user(mock_delete_user): - mock_delete_user.return_value = {} - status = admin_user.AdminUser() - (body, code, headers) = status.delete() - - mock_delete_user.assert_called_once_with( - admin_user_data.request_args_good["user_email"] - ) - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -@patch( - "api.src.admin_user.request", - MagicMock( - args=admin_user_data.request_args_bad, - ), -) -def test_entry_delete_schema(): - status = admin_user.AdminUser() - with pytest.raises(HTTPException): - status.delete() - - -# ##################################### Testing Functions ########################################## -# get_user_data -@patch("api.src.admin_user.pgquery.query_db") -def test_get_user_data_all(mock_query_db): - mock_query_db.return_value = admin_user_data.get_user_data_return - expected_result = admin_user_data.get_user_data_expected - expected_query = admin_user_data.expected_get_user_query - actual_result = admin_user.get_user_data("all", user_valid, []) - - mock_query_db.assert_called_with(expected_query, params={}) - assert actual_result == expected_result - - -@patch("api.src.admin_user.pgquery.query_db") -def test_get_user_data_email(mock_query_db): - mock_query_db.return_value = admin_user_data.get_user_data_return - expected_result = admin_user_data.get_user_data_expected[0] - expected_query = admin_user_data.expected_get_user_query_one - actual_result = admin_user.get_user_data("test@gmail.com", user_valid, []) - - mock_query_db.assert_called_with( - expected_query, params=admin_user_data.expected_get_user_query_one_params - ) - assert actual_result == expected_result - - -@patch("api.src.admin_user.pgquery.query_db") -def test_get_user_data_none(mock_query_db): - # get user should return an empty object if there are no users with specified email - mock_query_db.return_value = [] - expected_result = {} - actual_result = admin_user.get_user_data( - "test@gmail.com", - user_valid, - [], - ) - - mock_query_db.assert_called_with( - admin_user_data.expected_get_user_query_one, - params=admin_user_data.expected_get_user_query_one_params, - ) - assert actual_result == expected_result - - -# get_modify_user_data -@patch("api.src.admin_user.get_user_data") -def test_get_modify_rsu_data_all(mock_get_user_data): - mock_get_user_data.return_value = ["test user data"] - expected_rsu_data = {"user_data": ["test user data"]} - actual_result = admin_user.get_modify_user_data_authorized( - "all", - ) - - assert actual_result == expected_rsu_data - - -@patch("api.src.admin_user.admin_new_user.get_allowed_selections") -@patch("api.src.admin_user.get_user_data") -def test_get_modify_rsu_data_rsu(mock_get_user_data, mock_get_allowed_selections): - mock_get_allowed_selections.return_value = "test selections" - mock_get_user_data.return_value = "test user data" - expected_rsu_data = { - "user_data": "test user data", - "allowed_selections": "test selections", - } - actual_result = admin_user.get_modify_user_data_authorized( - "test@gmail.com", - ) - - assert actual_result == expected_rsu_data - - -# check_safe_input -def test_check_safe_input(): - expected_result = True - actual_result = admin_user.check_safe_input(admin_user_data.request_json_good) - assert actual_result == expected_result - - -def test_check_safe_input_bad(): - expected_result = False - actual_result = admin_user.check_safe_input( - admin_user_data.request_json_unsafe_input - ) - assert actual_result == expected_result - - -# modify_user -@patch("api.src.admin_user.check_safe_input") -@patch("api.src.admin_user.admin_new_user.check_email") -@patch("api.src.admin_user.pgquery.write_db") -def test_modify_user_success(mock_pgquery, mock_check_email, mock_check_safe_input): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - expected_msg = {"message": "User successfully modified"} - actual_msg = admin_user.modify_user( - "test@gmail.com", admin_user_data.request_json_good - ) - - calls = [ - call( - admin_user_data.modify_user_sql, params=admin_user_data.modify_user_params - ), - call(admin_user_data.add_org_sql, params=admin_user_data.add_org_params), - call(admin_user_data.modify_org_sql, params=admin_user_data.modify_org_params), - call(admin_user_data.remove_org_sql, params=admin_user_data.remove_org_params), - ] - mock_pgquery.assert_has_calls(calls) - assert actual_msg == expected_msg - - -@patch("api.src.admin_user.admin_new_user.check_email") -@patch("api.src.admin_user.pgquery.write_db") -def test_modify_user_email_check_fail(mock_pgquery, mock_check_email): - mock_check_email.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_user.modify_user("test@gmail.com", admin_user_data.request_json_good) - - assert str(exc_info.value) == "400 Bad Request: Email is not valid" - mock_pgquery.assert_has_calls([]) - - -@patch("api.src.admin_user.check_safe_input") -@patch("api.src.admin_user.admin_new_user.check_email") -@patch("api.src.admin_user.pgquery.write_db") -def test_modify_user_check_fail(mock_pgquery, mock_check_email, mock_check_safe_input): - mock_check_email.return_value = True - mock_check_safe_input.return_value = False - - with pytest.raises(BadRequest) as exc_info: - admin_user.modify_user( - "test@gmail.com", - admin_user_data.request_json_good, - ) - - assert ( - str(exc_info.value) - == "400 Bad Request: No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" - ) - - -@patch("api.src.admin_user.check_safe_input") -@patch("api.src.admin_user.admin_new_user.check_email") -@patch("api.src.admin_user.pgquery.write_db") -def test_modify_user_generic_exception( - mock_pgquery, mock_check_email, mock_check_safe_input -): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - mock_pgquery.side_effect = SQLAlchemyError("Test") - - with pytest.raises(InternalServerError) as exc_info: - admin_user.modify_user("test@gmail.com", admin_user_data.request_json_good) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue executing query" - ) - - -@patch("api.src.admin_user.check_safe_input") -@patch("api.src.admin_user.admin_new_user.check_email") -@patch("api.src.admin_user.pgquery.write_db") -def test_modify_user_sql_exception( - mock_pgquery, mock_check_email, mock_check_safe_input -): - mock_check_email.return_value = True - mock_check_safe_input.return_value = True - orig = MagicMock() - orig.args = ({"D": "SQL issue encountered"},) - mock_pgquery.side_effect = IntegrityError("", {}, orig) - - with pytest.raises(InternalServerError) as exc_info: - admin_user.modify_user("test@gmail.com", admin_user_data.request_json_good) - - assert str(exc_info.value) == "500 Internal Server Error: SQL issue encountered" - - -# delete_user -@patch("api.src.admin_user.pgquery.write_db") -def test_delete_user(mock_write_db): - expected_result = {"message": "User successfully deleted"} - actual_result = admin_user.delete_user_authorized("test@gmail.com") - - calls = [ - call(admin_user_data.delete_user_calls[0], params={"email": "test@gmail.com"}), - call(admin_user_data.delete_user_calls[1], params={"email": "test@gmail.com"}), - ] - mock_write_db.assert_has_calls(calls) - assert actual_result == expected_result diff --git a/services/api/tests/src/test_contact_support.py b/services/api/tests/src/test_contact_support.py deleted file mode 100644 index 694b57a47..000000000 --- a/services/api/tests/src/test_contact_support.py +++ /dev/null @@ -1,265 +0,0 @@ -import os -from unittest.mock import MagicMock - -import pytest - -import api.src.contact_support as contact_support -import api.tests.data.contact_support_data as contact_support_data - -DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS = "smtp.gmail.com" -DEFAULT_CSM_TARGET_SMTP_SERVER_PORT = 587 - - -# tests for ContactSupportSchema class --- -def test_contact_support_schema(): - # prepare - schema = contact_support.ContactSupportSchema() - - # execute - exceptionOccurred = False - try: - schema.load(contact_support_data.contact_support_data) - except Exception as e: - exceptionOccurred = True - - # assert - assert exceptionOccurred == False - - -def test_contact_support_schema_invalid(): - # prepare - schema = contact_support.ContactSupportSchema() - - # execute - with pytest.raises(Exception): - schema.load({}) - - -# end of tests for ContactSupportSchema class --- - - -# tests for ContactSupportResource class --- -def test_contact_support_resource_initialization_success(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - - # execute - contactSupportResource = contact_support.ContactSupportResource() - - # assert - assert ( - contactSupportResource.CSM_EMAIL_TO_SEND_FROM - == contact_support_data.CSM_EMAIL_TO_SEND_FROM - ) - assert ( - contactSupportResource.CSM_EMAIL_APP_PASSWORD - == contact_support_data.CSM_EMAIL_APP_PASSWORD - ) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_contact_support_resource_initialization_no_CSM_EMAIL_TO_SEND_FROM(): - # prepare - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - - # execute - exceptionOccurred = False - try: - contactSupportResource = contact_support.ContactSupportResource() - except Exception as e: - exceptionOccurred = True - - # assert - assert exceptionOccurred - - # cleanup - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_contact_support_resource_initialization_no_CSM_EMAIL_APP_PASSWORD(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - - # execute - exceptionOccurred = False - try: - contactSupportResource = contact_support.ContactSupportResource() - except Exception as e: - exceptionOccurred = True - - # assert - assert exceptionOccurred - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_options(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - contactSupportResource = contact_support.ContactSupportResource() - - # execute - result = contactSupportResource.options() - - # assert - assert result == ("", 204, contactSupportResource.options_headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_post_success(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - contactSupportResource = contact_support.ContactSupportResource() - contactSupportResource.validate_input = MagicMock() - contactSupportResource.send = MagicMock() - contact_support.abort = MagicMock() - contact_support.request = MagicMock() - - # execute - result = contactSupportResource.post() - - # assert - assert result == ("", 200, contactSupportResource.headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_post_no_json_body(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - contactSupportResource = contact_support.ContactSupportResource() - contactSupportResource.validate_input = MagicMock() - contactSupportResource.send = MagicMock() - contact_support.abort = MagicMock() - contact_support.request = MagicMock() - contact_support.request.json = None - - # execute - result = contactSupportResource.post() - - # assert - assert contact_support.abort.call_count == 2 - assert result == ("", 200, contactSupportResource.headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -def test_validate_input(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = contact_support_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = contact_support_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = contact_support_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) - os.environ["CSM_TLS_ENABLED"] = "true" - os.environ["CSM_AUTH_ENABLED"] = "true" - contactSupportResource = contact_support.ContactSupportResource() - - # execute - result = contactSupportResource.validate_input( - contact_support_data.contact_support_data - ) - - # assert - assert result == None - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - del os.environ["CSM_TLS_ENABLED"] - del os.environ["CSM_AUTH_ENABLED"] - - -# end of tests for ContactSupportResource class --- diff --git a/services/api/tests/src/test_iss_scms_status.py b/services/api/tests/src/test_iss_scms_status.py deleted file mode 100644 index 21bf7d841..000000000 --- a/services/api/tests/src/test_iss_scms_status.py +++ /dev/null @@ -1,101 +0,0 @@ -from unittest.mock import patch, MagicMock -import api.src.iss_scms_status as iss_scms_status -import api.tests.data.iss_scms_status_data as iss_scms_status_data - - -# ##################################### Testing Requests ########################################## -def test_request_options(): - info = iss_scms_status.IssScmsStatus() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET" - - -@patch("api.src.iss_scms_status.get_iss_scms_status") -def test_entry_get(mock_get_iss_scms_status): - mock_get_iss_scms_status.return_value = {} - status = iss_scms_status.IssScmsStatus() - (body, code, headers) = status.get() - - mock_get_iss_scms_status.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {} - - -# ##################################### Testing Functions ########################################## -@patch("api.src.iss_scms_status.pgquery") -def test_get_iss_status_no_data(mock_pgquery): - mock_pgquery.query_db.return_value = {} - expected_rsu_data = {} - expected_query = ( - "SELECT jsonb_build_object('ip', rd.ipv4_address, 'health', scms_health_data.health, 'expiration', scms_health_data.expiration) " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "LEFT JOIN (" - "SELECT a.rsu_id, a.health, a.timestamp, a.expiration " - "FROM (" - "SELECT sh.rsu_id, sh.health, sh.timestamp, sh.expiration, ROW_NUMBER() OVER (PARTITION BY sh.rsu_id order by sh.timestamp DESC) AS row_id " - "FROM public.scms_health AS sh" - ") AS a " - "WHERE a.row_id <= 1 ORDER BY rsu_id" - ") AS scms_health_data ON rd.rsu_id = scms_health_data.rsu_id " - "WHERE ron_v.name = :org_name " - "ORDER BY rd.ipv4_address" - ) - actual_result = iss_scms_status.get_iss_scms_status("Test") - mock_pgquery.query_db.assert_called_with( - expected_query, params={"org_name": "Test"} - ) - - assert actual_result == expected_rsu_data - - -@patch("api.src.iss_scms_status.pgquery") -def test_get_iss_status_single_result(mock_pgquery): - mock_pgquery.query_db.return_value = iss_scms_status_data.return_value_single_result - actual_result = iss_scms_status.get_iss_scms_status("Test") - mock_pgquery.query_db.assert_called_once() - - assert actual_result == iss_scms_status_data.expected_rsu_data_single_result - - -@patch("api.src.iss_scms_status.pgquery") -def test_get_iss_status_single_null_result(mock_pgquery): - mock_pgquery.query_db.return_value = ( - iss_scms_status_data.return_value_single_null_result - ) - actual_result = iss_scms_status.get_iss_scms_status("Test") - mock_pgquery.query_db.assert_called_once() - - assert actual_result == iss_scms_status_data.expected_rsu_data_single_null_result - - -@patch("api.src.iss_scms_status.pgquery") -def test_get_iss_status_multiple_result(mock_pgquery): - mock_pgquery.query_db.return_value = ( - iss_scms_status_data.return_value_multiple_result - ) - actual_result = iss_scms_status.get_iss_scms_status("Test") - mock_pgquery.query_db.assert_called_once() - - assert actual_result == iss_scms_status_data.expected_rsu_data_multiple_result - - -# test that get_iss_scms_status is calling pgquery.query_db with expected arguments -@patch( - "common.pgquery.db_config", - new={"pool_size": 5, "max_overflow": 2, "pool_timeout": 30, "pool_recycle": 1800}, -) -@patch("common.pgquery.db", new=None) -@patch("common.pgquery.query_db") -def test_get_iss_scms_status_query(mock_query_db): - # mock return values for function dependencies - mock_query_db.return_value = iss_scms_status_data.return_value_single_result - - result = iss_scms_status.get_iss_scms_status("Test") - assert result == iss_scms_status_data.expected_rsu_data_single_result - iss_scms_status.pgquery.query_db.assert_called_once_with( - iss_scms_status_data.expectedQuery, params={"org_name": "Test"} - ) diff --git a/services/api/tests/src/test_middleware.py b/services/api/tests/src/test_middleware.py index 53d518c32..955056df6 100644 --- a/services/api/tests/src/test_middleware.py +++ b/services/api/tests/src/test_middleware.py @@ -1,5 +1,4 @@ from unittest.mock import patch, Mock - from api.src import middleware from api.tests.data import auth_data from werkzeug.exceptions import Unauthorized @@ -42,7 +41,7 @@ def test_get_user_role(mock_get_user_info, mock_keycloak, mock_jwt): def test_middleware_class_call_options(mock_kc, mock_request, mock_get_user_role): # mock mock_request.return_value.method = "OPTIONS" - mock_request.return_value.path = "/user-auth" + mock_request.return_value.path = "/rsuinfo" # create instance app = Mock() @@ -68,7 +67,7 @@ def test_middleware_class_call_user_unauthorized(mock_request, mock_get_user_rol middleware_instance = middleware.Middleware(app) # call mock_get_user_role.return_value = None - environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/user-auth"} + environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/rsuinfo"} start_response = Mock() # check response = middleware_instance(environ, start_response) @@ -85,7 +84,7 @@ def test_middleware_class_call_user_authorized(mock_request, mock_get_user_role) # create instance app = Mock() mock_request.return_value.method = "GET" - mock_request.return_value.path = "/user-auth" + mock_request.return_value.path = "/wzdx-feed" mock_request.return_value.headers = {"Authorization": "test"} middleware_instance = middleware.Middleware(app) # call @@ -118,7 +117,7 @@ def test_middleware_class_call_exception( # create instance app = Mock() mock_request.return_value.method = "GET" - mock_request.return_value.path = "/user-auth" + mock_request.return_value.path = "/rsuinfo" mock_request.return_value.headers = {"Authorization": "token"} # call @@ -126,7 +125,7 @@ def test_middleware_class_call_exception( mock_keycloak_instance.introspect.side_effect = Unauthorized("test") mock_get_user_info.return_value = None - environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/user-auth"} + environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/rsuinfo"} start_response = Mock() middleware_instance = middleware.Middleware(app) @@ -137,62 +136,48 @@ def test_middleware_class_call_exception( mock_request.assert_called_once_with(environ) -@patch("api.src.middleware.get_user_role") -@patch("api.src.middleware.Request") -def test_middleware_class_call_contact_support(mock_request, mock_get_user_role): - # mock - mock_request.return_value.method = "POST" - mock_request.return_value.path = "/contact-support" - - # create instance - app = Mock() - middleware_instance = middleware.Middleware(app) - - # call - environ = {"GOOGLE_CLIENT_ID": "test"} - start_response = Mock() - middleware_instance(environ, start_response) - - # check - mock_get_user_role.assert_not_called() - app.assert_called_once_with(environ, start_response) - mock_request.assert_called_once_with(environ) - - -@patch("api.src.middleware.ENABLE_RSU_FEATURES", True) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", True) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", True) +@patch("api_environment.ENABLE_RSU_FEATURES", True) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", True) +@patch("api_environment.ENABLE_WZDX_FEATURES", True) def test_evaluate_tag_all_enabled(): - assert not middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) - assert not middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) - assert not middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) is False + assert ( + middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) + is False + ) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) is False -@patch("api.src.middleware.ENABLE_RSU_FEATURES", False) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", False) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", False) +@patch("api_environment.ENABLE_RSU_FEATURES", False) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", False) +@patch("api_environment.ENABLE_WZDX_FEATURES", False) def test_evaluate_tag_all_disabled(): from api.src import middleware as middleware - assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) - assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) - assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) is True + assert ( + middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) is True + ) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) is True -@patch("api.src.middleware.ENABLE_RSU_FEATURES", False) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", True) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", False) +@patch("api_environment.ENABLE_RSU_FEATURES", False) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", True) +@patch("api_environment.ENABLE_WZDX_FEATURES", False) def test_evaluate_tag_different(): from api.src import middleware as middleware - assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) - assert not middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) - assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.RSU) is True + assert ( + middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.INTERSECTION) + is False + ) + assert middleware.is_tag_disabled(middleware.FEATURE_KEYS_LITERAL.WZDX) is True -@patch("api.src.middleware.ENABLE_RSU_FEATURES", False) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", False) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", False) +@patch("api_environment.ENABLE_RSU_FEATURES", False) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", False) +@patch("api_environment.ENABLE_WZDX_FEATURES", False) def test_is_feature_disabled_disabled(): from api.src import middleware as middleware @@ -203,16 +188,16 @@ def test_is_feature_disabled_disabled(): "/d": None, } - assert middleware.is_endpoint_disabled(feature_tags, "/a") - assert middleware.is_endpoint_disabled(feature_tags, "/b") - assert middleware.is_endpoint_disabled(feature_tags, "/c") - assert not middleware.is_endpoint_disabled(feature_tags, "/d") - assert not middleware.is_endpoint_disabled(feature_tags, "/f") + assert middleware.is_endpoint_disabled(feature_tags, "/a") is True + assert middleware.is_endpoint_disabled(feature_tags, "/b") is True + assert middleware.is_endpoint_disabled(feature_tags, "/c") is True + assert middleware.is_endpoint_disabled(feature_tags, "/d") is False + assert middleware.is_endpoint_disabled(feature_tags, "/f") is False -@patch("api.src.middleware.ENABLE_RSU_FEATURES", True) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", True) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", True) +@patch("api_environment.ENABLE_RSU_FEATURES", True) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", True) +@patch("api_environment.ENABLE_WZDX_FEATURES", True) def test_is_feature_disabled_enabled(): from api.src import middleware as middleware @@ -223,16 +208,16 @@ def test_is_feature_disabled_enabled(): "/d": None, } - assert not middleware.is_endpoint_disabled(feature_tags, "/a") - assert not middleware.is_endpoint_disabled(feature_tags, "/b") - assert not middleware.is_endpoint_disabled(feature_tags, "/c") - assert not middleware.is_endpoint_disabled(feature_tags, "/d") - assert not middleware.is_endpoint_disabled(feature_tags, "/f") + assert middleware.is_endpoint_disabled(feature_tags, "/a") is False + assert middleware.is_endpoint_disabled(feature_tags, "/b") is False + assert middleware.is_endpoint_disabled(feature_tags, "/c") is False + assert middleware.is_endpoint_disabled(feature_tags, "/d") is False + assert middleware.is_endpoint_disabled(feature_tags, "/f") is False -@patch("api.src.middleware.ENABLE_RSU_FEATURES", True) -@patch("api.src.middleware.ENABLE_INTERSECTION_FEATURES", False) -@patch("api.src.middleware.ENABLE_WZDX_FEATURES", False) +@patch("api_environment.ENABLE_RSU_FEATURES", True) +@patch("api_environment.ENABLE_INTERSECTION_FEATURES", False) +@patch("api_environment.ENABLE_WZDX_FEATURES", False) def test_is_feature_disabled_different(): from api.src import middleware as middleware @@ -243,8 +228,8 @@ def test_is_feature_disabled_different(): "/d": None, } - assert not middleware.is_endpoint_disabled(feature_tags, "/a") - assert middleware.is_endpoint_disabled(feature_tags, "/b") - assert middleware.is_endpoint_disabled(feature_tags, "/c") - assert not middleware.is_endpoint_disabled(feature_tags, "/d") - assert not middleware.is_endpoint_disabled(feature_tags, "/f") + assert middleware.is_endpoint_disabled(feature_tags, "/a") is False + assert middleware.is_endpoint_disabled(feature_tags, "/b") is True + assert middleware.is_endpoint_disabled(feature_tags, "/c") is True + assert middleware.is_endpoint_disabled(feature_tags, "/d") is False + assert middleware.is_endpoint_disabled(feature_tags, "/f") is False diff --git a/services/api/tests/src/test_moove_ai_query.py b/services/api/tests/src/test_moove_ai_query.py deleted file mode 100644 index 27ad8e5ca..000000000 --- a/services/api/tests/src/test_moove_ai_query.py +++ /dev/null @@ -1,100 +0,0 @@ -from unittest.mock import patch, MagicMock - -import pytest -import api.src.moove_ai_query as moove_ai_query -import api.tests.data.moove_ai_query_data as moove_ai_query_data -import os -import pandas as pd -from werkzeug.exceptions import InternalServerError, BadRequest - -###################################### Testing Requests ########################################## - - -def test_request_options(): - mooveai = moove_ai_query.MooveAiData() - (body, code, headers) = mooveai.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "POST" - - -@patch("api.src.moove_ai_query.query_moove_ai") -def test_entry_post(mock_query_moove_ai): - req = MagicMock() - req.json = moove_ai_query_data.request_json_good - mock_query_moove_ai.return_value = [] - with patch("api.src.moove_ai_query.request", req): - mooveai = moove_ai_query.MooveAiData() - (body, code, headers) = mooveai.post() - - mock_query_moove_ai.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == [] - - -@patch("api.src.moove_ai_query.query_moove_ai") -def test_entry_post_bad_req(mock_query_moove_ai): - req = MagicMock() - req.json = moove_ai_query_data.request_json_bad - mock_query_moove_ai.return_value = [] - with patch("api.src.moove_ai_query.request", req): - mooveai = moove_ai_query.MooveAiData() - - with pytest.raises(BadRequest) as exc_info: - (body, code, headers) = mooveai.post() - - assert ( - str(exc_info.value) - == "400 Bad Request: {'geometry': ['Not a valid list.']}" - ) - - -###################################### Testing Functions ########################################## -@patch.dict( - os.environ, - { - "GCP_PROJECT_ID": "test_project", - "MOOVE_AI_SEGMENT_AGG_STATS_TABLE": "test_segment_agg_stats_table", - "MOOVE_AI_SEGMENT_EVENT_STATS_TABLE": "test_segment_event_stats_table", - }, -) -@patch("api.src.moove_ai_query.bigquery") -def test_query_moove_ai(mock_bigquery): - mock_bq_client = MagicMock() - mock_bigquery.Client.return_value = mock_bq_client - mock_query_resp = MagicMock() - mock_bq_client.query.return_value = mock_query_resp - mock_query_resp.to_dataframe.return_value = pd.DataFrame( - moove_ai_query_data.query_return_data - ) - - resp = moove_ai_query.query_moove_ai(moove_ai_query_data.coordinate_list) - - mock_bq_client.query.assert_called_once_with(moove_ai_query_data.expected_bq_query) - assert resp == moove_ai_query_data.feature_list - - -@patch.dict( - os.environ, - { - "GCP_PROJECT_ID": "test_project", - "MOOVE_AI_SEGMENT_AGG_STATS_TABLE": "test_segment_agg_stats_table", - "MOOVE_AI_SEGMENT_EVENT_STATS_TABLE": "test_segment_event_stats_table", - }, -) -@patch("api.src.moove_ai_query.bigquery") -def test_query_moove_ai_exception(mock_bigquery): - mock_bq_client = MagicMock() - mock_bigquery.Client.return_value = mock_bq_client - mock_bq_client.query.side_effect = Exception("Test exception") - - with pytest.raises(InternalServerError) as exc_info: - moove_ai_query.query_moove_ai(moove_ai_query_data.coordinate_list) - - assert ( - str(exc_info.value) - == "500 Internal Server Error: Encountered unknown issue querying Moove AI data: Test exception" - ) - - mock_bq_client.query.assert_called_once() diff --git a/services/api/tests/src/test_rsu_error_summary.py b/services/api/tests/src/test_rsu_error_summary.py deleted file mode 100644 index a93b35d9a..000000000 --- a/services/api/tests/src/test_rsu_error_summary.py +++ /dev/null @@ -1,157 +0,0 @@ -import os -from unittest.mock import MagicMock - -import api.src.rsu_error_summary as rsu_error_summary -import api.tests.data.rsu_error_summary_data as rsu_error_summary_data - - -# RSUErrorSummarySchema class tests --- -def test_rsu_error_summary_schema(): - # prepare - schema = rsu_error_summary.RSUErrorSummarySchema() - - # execute - exceptionOccurred = False - try: - schema.load(rsu_error_summary_data.rsu_error_summary_data_good) - except Exception as e: - exceptionOccurred = True - - # assert - assert exceptionOccurred == False - - -def test_rsu_error_summary_schema_invalid(): - # prepare - schema = rsu_error_summary.RSUErrorSummarySchema() - - # execute - exceptionOccurred = False - try: - schema.load(rsu_error_summary_data.rsu_error_summary_data_bad) - except Exception as e: - exceptionOccurred = True - - # assert - assert exceptionOccurred == True - - -# RSUErrorSummaryResource class tests --- -def test_options(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT - ) - RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() - - # execute - result = RSUErrorSummaryResource.options() - - # assert - assert result == ("", 204, RSUErrorSummaryResource.options_headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - - -def test_post_success(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT - ) - RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() - RSUErrorSummaryResource.validate_input = MagicMock() - RSUErrorSummaryResource.send = MagicMock() - rsu_error_summary.abort = MagicMock() - rsu_error_summary.request = MagicMock() - - # execute - result = RSUErrorSummaryResource.post() - - # assert - assert result == ("", 200, RSUErrorSummaryResource.headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - - -def test_post_no_json_body(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT - ) - RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() - RSUErrorSummaryResource.validate_input = MagicMock() - RSUErrorSummaryResource.send = MagicMock() - rsu_error_summary.abort = MagicMock() - rsu_error_summary.request = MagicMock() - rsu_error_summary.request.json = None - - # execute - result = RSUErrorSummaryResource.post() - - # assert - assert rsu_error_summary.abort.call_count == 2 - assert result == ("", 200, RSUErrorSummaryResource.headers) - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] - - -def test_validate_input_good(): - # prepare - os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM - os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME - os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD - os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] = ( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS - ) - os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str( - rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT - ) - RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() - - # execute - result = RSUErrorSummaryResource.validate_input( - rsu_error_summary_data.rsu_error_summary_data_good - ) - - # assert - assert result == None - - # cleanup - del os.environ["CSM_EMAIL_TO_SEND_FROM"] - del os.environ["CSM_EMAIL_APP_USERNAME"] - del os.environ["CSM_EMAIL_APP_PASSWORD"] - del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] - del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] diff --git a/services/api/tests/src/test_rsu_geo_msg_query.py b/services/api/tests/src/test_rsu_geo_msg_query.py index a98cd244d..263d209ac 100644 --- a/services/api/tests/src/test_rsu_geo_msg_query.py +++ b/services/api/tests/src/test_rsu_geo_msg_query.py @@ -1,8 +1,6 @@ from copy import deepcopy from datetime import datetime, timedelta from unittest.mock import patch, MagicMock -import os - import pytest from api.src.rsu_geo_msg_query import ( query_geo_data_mongo, @@ -10,7 +8,6 @@ get_collection, create_geo_filter, ) -import json import api.tests.data.rsu_geo_msg_query_data as rsu_geo_msg_query_data from werkzeug.exceptions import InternalServerError @@ -20,15 +17,6 @@ def test_geo_hash(): assert result is not None -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_bsm(mock_mongo): mock_db = MagicMock() @@ -57,15 +45,6 @@ def test_query_geo_data_mongo_bsm(mock_mongo): assert response == expected_response -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_filter_failed(mock_mongo): mock_db = MagicMock() @@ -90,14 +69,6 @@ def test_query_geo_data_mongo_filter_failed(mock_mongo): mock_collection.find.assert_called() -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_failed_to_connect(mock_mongo): mock_mongo.side_effect = Exception("Failed to connect") @@ -114,15 +85,6 @@ def test_query_geo_data_mongo_failed_to_connect(mock_mongo): assert response == expected_response -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_PSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_psm(mock_mongo): mock_db = MagicMock() @@ -161,14 +123,6 @@ def test_query_geo_data_mongo_psm(mock_mongo): # mock_collection.find.assert_called() -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_unsupported_msg_type(mock_mongo): start = "2023-07-01T00:00:00Z" @@ -181,15 +135,8 @@ def test_query_geo_data_mongo_unsupported_msg_type(mock_mongo): assert response == [] -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "bsm_col", - "MONGO_PROCESSED_PSM_COLLECTION_NAME": "psm_col", - }, -) +@patch("api_environment.MONGO_PROCESSED_BSM_COLLECTION_NAME", "bsm_col") +@patch("api_environment.MONGO_PROCESSED_PSM_COLLECTION_NAME", "psm_col") @patch("api.src.rsu_geo_msg_query.MongoClient") def test_get_collection(mock_mongo): mock_db = MagicMock() @@ -236,15 +183,6 @@ def test_create_geo_filter(): assert filter["geometry"]["$geoWithin"]["$geometry"]["coordinates"] == [point_list] -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_geo_data_mongo_schema_version_filter(mock_mongo): mock_db = MagicMock() @@ -270,15 +208,6 @@ def test_query_geo_data_mongo_schema_version_filter(mock_mongo): # checks deduplication and sorting by timestamp -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "10000", - }, -) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_order_by_time_stamp(mock_mongo): mock_db = MagicMock() @@ -302,15 +231,8 @@ def test_order_by_time_stamp(mock_mongo): ) -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "name", - "MONGO_PROCESSED_BSM_COLLECTION_NAME": "col", - "MAX_GEO_QUERY_RECORDS": "5", - }, -) +@patch("api_environment.MONGO_PROCESSED_BSM_COLLECTION_NAME", "col") +@patch("api_environment.MAX_GEO_QUERY_RECORDS", 5) @patch("api.src.rsu_geo_msg_query.MongoClient") def test_query_limit(mock_mongo): mock_db = MagicMock() diff --git a/services/api/tests/src/test_rsu_online_status.py b/services/api/tests/src/test_rsu_online_status.py index 0e4e75983..4d5e28967 100644 --- a/services/api/tests/src/test_rsu_online_status.py +++ b/services/api/tests/src/test_rsu_online_status.py @@ -90,7 +90,10 @@ def test_ping_data_query(mock_pgquery): } rsu_online_status.get_ping_data(user_valid_no_super) - mock_pgquery.query_db.assert_called_with(expected_query, params=expected_params) + mock_pgquery.query_db.assert_called_with( + expected_query, + params=expected_params, + ) @patch("api.src.rsu_online_status.pgquery") diff --git a/services/api/tests/src/test_rsu_querycounts.py b/services/api/tests/src/test_rsu_querycounts.py index 3df026a0f..9c7a29c01 100644 --- a/services/api/tests/src/test_rsu_querycounts.py +++ b/services/api/tests/src/test_rsu_querycounts.py @@ -1,11 +1,10 @@ from unittest.mock import patch, MagicMock import pytest -import os import api.src.rsu_querycounts as rsu_querycounts -from api.src.rsu_querycounts import query_rsu_counts_mongo -import api.tests.data.rsu_querycounts_data as querycounts_data +from api.src.rsu_querycounts import query_rsu_counts_aggregated from api.tests.data import auth_data -from werkzeug.exceptions import BadRequest, Forbidden +from werkzeug.exceptions import Forbidden, InternalServerError +from datetime import datetime user_valid = auth_data.get_request_environ() @@ -19,169 +18,236 @@ def test_options_request(): assert headers["Access-Control-Allow-Methods"] == "GET" -@patch("api.src.rsu_querycounts.get_organization_rsus") -@patch("api.src.rsu_querycounts.query_rsu_counts_mongo") -@patch( - "api.src.rsu_querycounts.request", - MagicMock( - args=querycounts_data.request_args_good, - ), -) -def test_get_request(mock_query, mock_rsus): - counts = rsu_querycounts.RsuQueryCounts() - mock_rsus.return_value = ["10.0.0.1", "10.0.0.2", "10.0.0.3"] - mock_query.return_value = {"Some Data"} - (data, code, headers) = counts.get() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert headers["Content-Type"] == "application/json" - assert data == {"Some Data"} - - -# ################################## Testing Data Validation ######################################### -@patch.dict(os.environ, {"COUNTS_MSG_TYPES": "test,anothErtest"}) -@patch( - "api.src.rsu_querycounts.request", - MagicMock( - args=querycounts_data.request_args_bad_message, - ), -) -def test_get_request_invalid_message(): - counts = rsu_querycounts.RsuQueryCounts() +##################################### Test query_rsu_counts_aggregated ########################################### +@patch("api.src.rsu_querycounts.MongoClient") +@patch("api.src.rsu_querycounts.util.format_date_utc") +def test_query_rsu_counts_aggregated_success(mock_format_date, mock_mongo): + # Mock date formatting + mock_start_dt = datetime(2022, 1, 1, 0, 0, 0) + mock_end_dt = datetime(2023, 1, 1, 0, 0, 0) + mock_format_date.side_effect = [mock_start_dt, mock_end_dt] + + # Mock MongoDB connection and collection + mock_db = MagicMock() + mock_collection = MagicMock() + mock_mongo.return_value.__getitem__.return_value = mock_db + mock_db.__getitem__.return_value = mock_collection - with pytest.raises(BadRequest) as exc_info: - counts.get() + # Mock aggregation cursor result + mock_cursor = [ + { + "rsuIp": "192.168.0.1", + "messageTypeCounts": {"BSM": 100, "SPAT": 50, "SSM": 25}, + }, + { + "rsuIp": "192.168.0.2", + "messageTypeCounts": {"BSM": 200, "SPAT": 75}, + }, + ] + mock_collection.aggregate.return_value = iter(mock_cursor) - assert ( - str(exc_info.value) - == "400 Bad Request: Invalid Message Type.\nValid message types: Test, Anothertest" - ) + allowed_ips_dict = { + "192.168.0.1": "Route 1", + "192.168.0.2": "Route 2", + "192.168.0.3": "Route 3", + } + start = "2022-01-01T00:00:00" + end = "2023-01-01T00:00:00" + expected_result = { + "192.168.0.1": { + "road": "Route 1", + "messageTypeCounts": {"BSM": 100, "SPAT": 50, "SSM": 25}, + }, + "192.168.0.2": { + "road": "Route 2", + "messageTypeCounts": {"BSM": 200, "SPAT": 75}, + }, + "192.168.0.3": {"road": "Route 3", "messageTypeCounts": {}}, + } -@patch.dict(os.environ, {}, clear=True) -@patch( - "api.src.rsu_querycounts.request", - MagicMock( - args=querycounts_data.request_args_bad_message, - ), -) -def test_get_request_invalid_message_no_env(): - counts = rsu_querycounts.RsuQueryCounts() + result = query_rsu_counts_aggregated(allowed_ips_dict, start, end) - with pytest.raises(BadRequest) as exc_info: - counts.get() + assert result == expected_result + # Verify the aggregation pipeline was called with allowDiskUse=False + mock_collection.aggregate.assert_called_once() + call_args = mock_collection.aggregate.call_args + assert call_args[1]["allowDiskUse"] is False - assert ( - str(exc_info.value) - == "400 Bad Request: Invalid Message Type.\nValid message types: Bsm, Ssm, Spat, Srm, Map" - ) +@patch("api.src.rsu_querycounts.MongoClient") +@patch("api.src.rsu_querycounts.util.format_date_utc") +def test_query_rsu_counts_aggregated_empty_results(mock_format_date, mock_mongo): + # Mock date formatting + mock_start_dt = datetime(2022, 1, 1, 0, 0, 0) + mock_end_dt = datetime(2023, 1, 1, 0, 0, 0) + mock_format_date.side_effect = [mock_start_dt, mock_end_dt] + + # Mock MongoDB connection and collection + mock_db = MagicMock() + mock_collection = MagicMock() + mock_mongo.return_value.__getitem__.return_value = mock_db + mock_db.__getitem__.return_value = mock_collection -@patch( - "api.src.rsu_querycounts.request", - MagicMock( - args=querycounts_data.request_args_bad_type, - ), -) -def test_schema_validate_bad_data(): - counts = rsu_querycounts.RsuQueryCounts() - with pytest.raises(Exception): - assert counts.get() + # Mock empty aggregation cursor result + mock_collection.aggregate.return_value = iter([]) + allowed_ips_dict = { + "192.168.0.1": "Route 1", + "192.168.0.2": "Route 2", + } + start = "2022-01-01T00:00:00" + end = "2023-01-01T00:00:00" -# ################################## Test get_organization_rsus ######################################## -@patch("api.src.rsu_querycounts.pgquery") -def test_rsu_counts_get_organization_rsus(mock_pgquery): - mock_pgquery.query_db.return_value = [ - ({"ipv4_address": "10.11.81.12", "primary_route": "Route 1"},), - ({"ipv4_address": "10.11.81.13", "primary_route": "Route 1"},), - ({"ipv4_address": "10.11.81.14", "primary_route": "Route 1"},), - ] - expected_query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT rd.ipv4_address, rd.primary_route " - "FROM public.rsus rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "ORDER BY primary_route ASC, milepost ASC " - ") as row" - ) - - actual_result = rsu_querycounts.get_organization_rsus(user_valid, []) - - mock_pgquery.query_db.assert_called_with(expected_query, params={}) - assert actual_result == { - "10.11.81.12": "Route 1", - "10.11.81.13": "Route 1", - "10.11.81.14": "Route 1", + expected_result = { + "192.168.0.1": {"road": "Route 1", "messageTypeCounts": {}}, + "192.168.0.2": {"road": "Route 2", "messageTypeCounts": {}}, } + result = query_rsu_counts_aggregated(allowed_ips_dict, start, end) + + assert result == expected_result + -@patch("api.src.rsu_querycounts.pgquery") -def test_rsu_counts_get_organization_rsus_empty(mock_pgquery): - mock_pgquery.query_db.return_value = [] - expected_query = ( - "SELECT to_jsonb(row) " - "FROM (" - "SELECT rd.ipv4_address, rd.primary_route " - "FROM public.rsus rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "ORDER BY primary_route ASC, milepost ASC " - ") as row" - ) - actual_result = rsu_querycounts.get_organization_rsus(user_valid, []) - mock_pgquery.query_db.assert_called_with(expected_query, params={}) - - assert actual_result == {} - - -##################################### Test query_rsu_counts ########################################### -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "name"}, -) @patch("api.src.rsu_querycounts.MongoClient") -def test_query_rsu_counts_mongo_success(mock_mongo): +@patch("api.src.rsu_querycounts.logging") +def test_query_rsu_counts_aggregated_connection_failure(mock_logging, mock_mongo): + # Mock the MongoDB connection to throw an exception + mock_mongo.side_effect = Exception("Connection timeout") + + allowed_ips_dict = {"192.168.0.1": "Route 1"} + start = "2022-01-01T00:00:00" + end = "2023-01-01T00:00:00" + + with pytest.raises(Forbidden) as exc_info: + query_rsu_counts_aggregated(allowed_ips_dict, start, end) + + assert str(exc_info.value) == "403 Forbidden: Failed to connect to Mongo" + mock_logging.error.assert_called_once() + + +@patch("api.src.rsu_querycounts.MongoClient") +@patch("api.src.rsu_querycounts.util.format_date_utc") +@patch("api.src.rsu_querycounts.logging") +def test_query_rsu_counts_aggregated_aggregation_failure( + mock_logging, mock_format_date, mock_mongo +): + # Mock date formatting + mock_start_dt = datetime(2022, 1, 1, 0, 0, 0) + mock_end_dt = datetime(2023, 1, 1, 0, 0, 0) + mock_format_date.side_effect = [mock_start_dt, mock_end_dt] + + # Mock MongoDB connection and collection mock_db = MagicMock() mock_collection = MagicMock() mock_mongo.return_value.__getitem__.return_value = mock_db mock_db.__getitem__.return_value = mock_collection - mock_db.validate_collection.return_value = "valid" - # Mock data that would be returned from MongoDB - mock_collection.find_one.return_value = {"count": 5} + # Mock aggregation to throw an exception + mock_collection.aggregate.side_effect = Exception("Aggregation error") - allowed_ips = {"192.168.0.1": "A1", "192.168.0.2": "A2"} - message_type = "BSM" + allowed_ips_dict = {"192.168.0.1": "Route 1"} + start = "2022-01-01T00:00:00" + end = "2023-01-01T00:00:00" + + with pytest.raises(InternalServerError) as exc_info: + query_rsu_counts_aggregated(allowed_ips_dict, start, end) + + assert str(exc_info.value) == "500 Internal Server Error: Encountered unknown issue" + mock_logging.error.assert_called() + + +@patch("api.src.rsu_querycounts.MongoClient") +@patch("api.src.rsu_querycounts.util.format_date_utc") +def test_query_rsu_counts_aggregated_partial_results(mock_format_date, mock_mongo): + # Mock date formatting + mock_start_dt = datetime(2022, 1, 1, 0, 0, 0) + mock_end_dt = datetime(2023, 1, 1, 0, 0, 0) + mock_format_date.side_effect = [mock_start_dt, mock_end_dt] + + # Mock MongoDB connection and collection + mock_db = MagicMock() + mock_collection = MagicMock() + mock_mongo.return_value.__getitem__.return_value = mock_db + mock_db.__getitem__.return_value = mock_collection + + # Mock aggregation cursor with only one RSU having results + mock_cursor = [ + { + "rsuIp": "192.168.0.1", + "messageTypeCounts": {"BSM": 150}, + } + ] + mock_collection.aggregate.return_value = iter(mock_cursor) + + allowed_ips_dict = { + "192.168.0.1": "Route 1", + "192.168.0.2": "Route 2", + "192.168.0.3": "Route 3", + } start = "2022-01-01T00:00:00" end = "2023-01-01T00:00:00" expected_result = { - "192.168.0.1": {"road": "A1", "count": 5}, - "192.168.0.2": {"road": "A2", "count": 5}, + "192.168.0.1": {"road": "Route 1", "messageTypeCounts": {"BSM": 150}}, + "192.168.0.2": {"road": "Route 2", "messageTypeCounts": {}}, + "192.168.0.3": {"road": "Route 3", "messageTypeCounts": {}}, } - result = query_rsu_counts_mongo(allowed_ips, message_type, start, end) + result = query_rsu_counts_aggregated(allowed_ips_dict, start, end) assert result == expected_result -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "name"}, -) @patch("api.src.rsu_querycounts.MongoClient") -@patch("api.src.rsu_querycounts.logging") -def test_query_rsu_counts_mongo_failure(mock_logging, mock_mongo): - # Mock the MongoDB connection to throw an exception - mock_mongo.side_effect = Exception("Failed to connect") +@patch("api.src.rsu_querycounts.util.format_date_utc") +def test_query_rsu_counts_aggregated_pipeline_structure(mock_format_date, mock_mongo): + # Mock date formatting + mock_start_dt = datetime(2022, 1, 1, 0, 0, 0) + mock_end_dt = datetime(2023, 1, 1, 0, 0, 0) + mock_format_date.side_effect = [mock_start_dt, mock_end_dt] + + # Mock MongoDB connection and collection + mock_db = MagicMock() + mock_collection = MagicMock() + mock_mongo.return_value.__getitem__.return_value = mock_db + mock_db.__getitem__.return_value = mock_collection + mock_collection.aggregate.return_value = iter([]) - allowed_ips = ["192.168.0.1", "192.168.0.2"] - message_type = "TYPE_A" + allowed_ips_dict = {"192.168.0.1": "Route 1", "192.168.0.2": "Route 2"} start = "2022-01-01T00:00:00" end = "2023-01-01T00:00:00" - with pytest.raises(Forbidden) as exc_info: - query_rsu_counts_mongo(allowed_ips, message_type, start, end) + query_rsu_counts_aggregated(allowed_ips_dict, start, end) + + # Verify the pipeline structure + call_args = mock_collection.aggregate.call_args + pipeline = call_args[0][0] + + # Check match stage + assert pipeline[0]["$match"]["rsuIp"]["$in"] == ["192.168.0.1", "192.168.0.2"] + assert pipeline[0]["$match"]["timestamp"]["$gte"] == mock_start_dt + assert pipeline[0]["$match"]["timestamp"]["$lt"] == mock_end_dt + + # Check project stage + assert "$project" in pipeline[1] + assert pipeline[1]["$project"]["rsuIp"] == 1 + assert pipeline[1]["$project"]["messageType"] == 1 + assert pipeline[1]["$project"]["count"] == 1 + assert pipeline[1]["$project"]["_id"] == 0 + + # Check first group stage + assert "$group" in pipeline[2] + assert pipeline[2]["$group"]["_id"] == { + "rsuIp": "$rsuIp", + "messageType": "$messageType", + } - assert str(exc_info.value) == "403 Forbidden: Failed to connect to Mongo" + # Check second group stage + assert "$group" in pipeline[3] + assert pipeline[3]["$group"]["_id"] == "$_id.rsuIp" + + # Check final project stage + assert "$project" in pipeline[4] + assert pipeline[4]["$project"]["_id"] == 0 + assert pipeline[4]["$project"]["rsuIp"] == "$_id" diff --git a/services/api/tests/src/test_rsu_snmp_fwd_fetch.py b/services/api/tests/src/test_rsu_snmp_fwd_fetch.py new file mode 100644 index 000000000..0fc2dd124 --- /dev/null +++ b/services/api/tests/src/test_rsu_snmp_fwd_fetch.py @@ -0,0 +1,203 @@ +from unittest.mock import patch, MagicMock, ANY +import pytest +from flask import Flask, request +from rsu_snmp_fwd_fetch import RsuSnmpFwdFetch +from common.auth_tools import PermissionResult, EnvironWithOrg, ORG_ROLE_LITERAL, ENVIRON_USER_KEY, UserInfo + +from werkzeug.exceptions import InternalServerError, BadRequest, NotFound + +@pytest.fixture +def app(): + app = Flask(__name__) + return app + +@pytest.fixture +def permission_result(): + mock_user_info = MagicMock(spec=UserInfo) + mock_user_info.super_user = False + mock_user_info.organizations = {"TestOrg": "admin"} + + user = EnvironWithOrg(mock_user_info, "TestOrg", ORG_ROLE_LITERAL.USER) + + return PermissionResult(allowed=True, qualified_orgs=["TestOrg"], message=None, user=user) + +# #################################### Testing Requests ########################################### + +def test_options_request(): + resource = RsuSnmpFwdFetch() + (body, code, headers) = resource.options() + assert body == "" + assert code == 204 + assert headers["Access-Control-Allow-Methods"] == "GET" + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +@patch("rsu_snmp_fwd_fetch.UpdatePostgresRsuMessageForward") +@patch("rsu_snmp_fwd_fetch.rsu_message_forward_helpers") +def test_get_request_success(mock_helpers, mock_update_pg, mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_encrypt_pw": "enc", + "snmp_version": "v3" + } + mock_fetch_info.return_value = rsu_info + + mock_updater = MagicMock() + mock_update_pg.return_value = mock_updater + mock_updater.get_snmp_configs.return_value = {1: "some_configs"} + + mock_helpers.format_snmp_msgfwd_configs.return_value = {"formatted": "data"} + + resource = RsuSnmpFwdFetch() + (data, code, headers) = resource.get() + + assert code == 200 + assert data == {"formatted": "data"} + mock_fetch_info.assert_called_once_with("10.0.0.1", ANY) + mock_updater.get_snmp_configs.assert_called_once() + args, _ = mock_updater.get_snmp_configs.call_args + assert args[0][0]["ipv4_address"] == "10.0.0.1" + +def test_get_request_invalid_schema(app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "invalid-ip"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + resource = RsuSnmpFwdFetch() + with pytest.raises(Exception) as excinfo: + resource.get() + assert "400" in str(excinfo.value) + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +def test_get_request_rsu_not_found(mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + mock_fetch_info.return_value = None + + resource = RsuSnmpFwdFetch() + with pytest.raises(NotFound) as excinfo: + resource.get() + assert "404" in str(excinfo.value) + assert "not found in organization" in str(excinfo.value) + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +def test_get_request_missing_required_fields(mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + # Missing snmp_version + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_encrypt_pw": "enc" + } + mock_fetch_info.return_value = rsu_info + + resource = RsuSnmpFwdFetch() + with pytest.raises(InternalServerError) as excinfo: + resource.get() + assert excinfo.value.code == 500 + assert "RSU info missing required fields" in str(excinfo.value) + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +@patch("rsu_snmp_fwd_fetch.UpdatePostgresRsuMessageForward") +@patch("rsu_snmp_fwd_fetch.rsu_message_forward_helpers") +def test_get_request_missing_snmp_encrypt_pw_field(mock_helpers, mock_update_pg, mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_version": "v3" + } + mock_fetch_info.return_value = rsu_info + + mock_updater = MagicMock() + mock_update_pg.return_value = mock_updater + mock_updater.get_snmp_configs.return_value = {1: "some_configs"} + + mock_helpers.format_snmp_msgfwd_configs.return_value = {"formatted": "data"} + + resource = RsuSnmpFwdFetch() + (data, code, headers) = resource.get() + + assert code == 200 + assert data == {"formatted": "data"} + mock_fetch_info.assert_called_once_with("10.0.0.1", ANY) + mock_updater.get_snmp_configs.assert_called_once() + args, _ = mock_updater.get_snmp_configs.call_args + assert args[0][0]["ipv4_address"] == "10.0.0.1" + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +@patch("rsu_snmp_fwd_fetch.UpdatePostgresRsuMessageForward") +def test_get_request_unable_to_retrieve(mock_update_pg, mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_encrypt_pw": "enc", + "snmp_version": "v3" + } + mock_fetch_info.return_value = rsu_info + + mock_updater = MagicMock() + mock_update_pg.return_value = mock_updater + mock_updater.get_snmp_configs.return_value = {1: "Unable to retrieve latest SNMP config"} + + resource = RsuSnmpFwdFetch() + with pytest.raises(InternalServerError) as excinfo: + resource.get() + assert excinfo.value.code == 500 + assert "Error fetching SNMP configs" in str(excinfo.value) + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +@patch("rsu_snmp_fwd_fetch.UpdatePostgresRsuMessageForward") +def test_get_request_unsupported_version(mock_update_pg, mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_encrypt_pw": "enc", + "snmp_version": "v3" + } + mock_fetch_info.return_value = rsu_info + + mock_updater = MagicMock() + mock_update_pg.return_value = mock_updater + mock_updater.get_snmp_configs.return_value = {1: "Unsupported SNMP version"} + + resource = RsuSnmpFwdFetch() + with pytest.raises(InternalServerError) as excinfo: + resource.get() + assert excinfo.value.code == 500 + assert "Error fetching SNMP configs" in str(excinfo.value) + +@patch("rsu_snmp_fwd_fetch.fetch_rsu_info") +@patch("rsu_snmp_fwd_fetch.UpdatePostgresRsuMessageForward") +def test_get_request_exception(mock_update_pg, mock_fetch_info, app, permission_result): + with app.test_request_context(query_string={"rsu_ip": "10.0.0.1"}): + request.environ[ENVIRON_USER_KEY] = permission_result.user + rsu_info = { + "rsu_id": 1, + "snmp_username": "user", + "snmp_password": "pw", + "snmp_encrypt_pw": "enc", + "snmp_version": "v3" + } + mock_fetch_info.return_value = rsu_info + + mock_updater = MagicMock() + mock_update_pg.return_value = mock_updater + mock_updater.get_snmp_configs.side_effect = Exception("Test Exception") + + resource = RsuSnmpFwdFetch() + with pytest.raises(InternalServerError) as excinfo: + resource.get() + assert excinfo.value.code == 500 + assert "Error fetching SNMP configs" in str(excinfo.value) diff --git a/services/api/tests/src/test_rsu_ssm_srm.py b/services/api/tests/src/test_rsu_ssm_srm.py index b16d5d704..ba2c5eea4 100644 --- a/services/api/tests/src/test_rsu_ssm_srm.py +++ b/services/api/tests/src/test_rsu_ssm_srm.py @@ -49,15 +49,7 @@ def test_get_request(mock_get_rsu_dict, mock_srm, mock_ssm): ] -@patch.dict( - os.environ, - { - "MONGO_DB_URI": "uri", - "MONGO_DB_NAME": "db", - "SSM_DB_NAME": "collection", - "SRM_DB_NAME": "srm_collection", - }, -) +@patch("api_environment.MONGO_SSM_COLLECTION_NAME", "ssm_collection") @patch("api.src.rsu_ssm_srm.query_ssm_data_mongo") @patch("api.src.rsu_ssm_srm.query_srm_data_mongo") @patch("api.src.rsu_ssm_srm.get_rsu_set_for_org") @@ -79,10 +71,7 @@ def test_get_request_invalid(mock_get_rsu_dict, mock_srm, mock_ssm): # ################################### Test query_ssm_data ######################################## -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SSM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SSM_COLLECTION_NAME", "ssm_collection") @patch("api.src.rsu_ssm_srm.MongoClient") @patch("api.src.rsu_ssm_srm.datetime") def test_query_ssm_data_query(mock_date, mock_mongo): @@ -103,10 +92,7 @@ def test_query_ssm_data_query(mock_date, mock_mongo): mock_collection.find.assert_called() -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SSM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SSM_COLLECTION_NAME", "Fake_table") @patch("api.src.rsu_ssm_srm.MongoClient") def test_query_ssm_data_no_data(mock_mongo): mock_db = MagicMock() @@ -115,15 +101,11 @@ def test_query_ssm_data_no_data(mock_mongo): mock_db.__getitem__.return_value = mock_collection mock_collection.find.return_value = [] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SSM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_ssm_data_mongo() - assert data == [] + data = rsu_ssm_srm.query_ssm_data_mongo() + assert data == [] -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SSM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SRM_COLLECTION_NAME", "Fake_table") @patch("api.src.rsu_ssm_srm.MongoClient") def test_query_ssm_data_single_result(mock_mongo): mock_db = MagicMock() @@ -132,9 +114,8 @@ def test_query_ssm_data_single_result(mock_mongo): mock_db.__getitem__.return_value = mock_collection mock_collection.find.return_value = [ssm_srm_data.ssm_record_one] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SSM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_ssm_data_mongo() - assert data == ssm_srm_data.ssm_single_result_expected + data = rsu_ssm_srm.query_ssm_data_mongo() + assert data == ssm_srm_data.ssm_single_result_expected @patch.dict( @@ -153,16 +134,12 @@ def test_query_ssm_data_multiple_result(mock_mongo): ssm_srm_data.ssm_record_two, ssm_srm_data.ssm_record_three, ] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SSM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_ssm_data_mongo() - assert data == ssm_srm_data.ssm_multiple_result_expected + data = rsu_ssm_srm.query_ssm_data_mongo() + assert data == ssm_srm_data.ssm_multiple_result_expected # #################################### Test query_srm_data ########################################### -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SRM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SRM_COLLECTION_NAME", "Fake_table") @patch("api.src.rsu_ssm_srm.MongoClient") @patch("api.src.rsu_ssm_srm.datetime") def test_query_srm_data_query(mock_date, mock_mongo): @@ -175,16 +152,12 @@ def test_query_srm_data_query(mock_date, mock_mongo): mock_date.now.return_value = datetime.strptime( "2022/12/14 00:00:00", "%Y/%m/%d %H:%M:%S" ).astimezone(UTC) - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SRM_DB_NAME": "Fake_table"}): - rsu_ssm_srm.query_srm_data_mongo() - mock_mongo.assert_called() - mock_collection.find.assert_called() + rsu_ssm_srm.query_srm_data_mongo() + mock_mongo.assert_called() + mock_collection.find.assert_called() -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SRM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SRM_COLLECTION_NAME", "ssm_collection") @patch("api.src.rsu_ssm_srm.MongoClient") def test_query_srm_data_no_data(mock_mongo): mock_db = MagicMock() @@ -193,15 +166,11 @@ def test_query_srm_data_no_data(mock_mongo): mock_db.__getitem__.return_value = mock_collection mock_collection.find.return_value = [] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SRM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_srm_data_mongo() - assert data == [] + data = rsu_ssm_srm.query_srm_data_mongo() + assert data == [] -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SRM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SRM_COLLECTION_NAME", "Fake_table") @patch("api.src.rsu_ssm_srm.MongoClient") def test_query_srm_data_single_result(mock_mongo): mock_db = MagicMock() @@ -210,15 +179,11 @@ def test_query_srm_data_single_result(mock_mongo): mock_db.__getitem__.return_value = mock_collection mock_collection.find.return_value = [ssm_srm_data.srm_record_one] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SRM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_srm_data_mongo() - assert data == ssm_srm_data.srm_single_result_expected + data = rsu_ssm_srm.query_srm_data_mongo() + assert data == ssm_srm_data.srm_single_result_expected -@patch.dict( - os.environ, - {"MONGO_DB_URI": "uri", "MONGO_DB_NAME": "db", "SRM_DB_NAME": "collection"}, -) +@patch("api_environment.MONGO_SRM_COLLECTION_NAME", "Fake_table") @patch("api.src.rsu_ssm_srm.MongoClient") def test_query_srm_data_multiple_result(mock_mongo): mock_db = MagicMock() @@ -231,6 +196,5 @@ def test_query_srm_data_multiple_result(mock_mongo): ssm_srm_data.srm_record_two, ssm_srm_data.srm_record_three, ] - with patch.dict("api.src.rsu_ssm_srm.os.environ", {"SRM_DB_NAME": "Fake_table"}): - data = rsu_ssm_srm.query_srm_data_mongo() - assert data == ssm_srm_data.srm_multiple_result_expected + data = rsu_ssm_srm.query_srm_data_mongo() + assert data == ssm_srm_data.srm_multiple_result_expected diff --git a/services/api/tests/src/test_rsu_upgrade.py b/services/api/tests/src/test_rsu_upgrade.py index 8331d97d1..3e2a489b0 100644 --- a/services/api/tests/src/test_rsu_upgrade.py +++ b/services/api/tests/src/test_rsu_upgrade.py @@ -2,7 +2,6 @@ import pytest from api.src import rsu_upgrade -import os from werkzeug.exceptions import Conflict @@ -77,7 +76,7 @@ def test_check_for_upgrade_false(mock_query_db): assert actual_response == expected_response -@patch.dict(os.environ, {"FIRMWARE_MANAGER_ENDPOINT": "http://1.1.1.1:8080"}) +@patch("api_environment.FIRMWARE_MANAGER_ENDPOINT", "http://1.1.1.1:8080") @patch("api.src.rsu_upgrade.requests.post") @patch("api.src.rsu_upgrade.pgquery.write_db") @patch( @@ -116,7 +115,7 @@ def test_mark_rsu_for_upgrade_eligible( assert actual_status_code == expected_status_code -@patch.dict(os.environ, {"FIRMWARE_MANAGER_ENDPOINT": "http://1.1.1.1:8080"}) +@patch("api_environment.FIRMWARE_MANAGER_ENDPOINT", "http://1.1.1.1:8080") @patch("api.src.rsu_upgrade.requests.post") @patch("api.src.rsu_upgrade.pgquery.write_db") @patch( @@ -157,7 +156,7 @@ def test_mark_rsu_for_upgrade_eligible_but_rejected( assert actual_status_code == expected_status_code -@patch.dict(os.environ, {"FIRMWARE_MANAGER_ENDPOINT": "http://1.1.1.1:8080"}) +@patch("api_environment.FIRMWARE_MANAGER_ENDPOINT", "http://1.1.1.1:8080") @patch("api.src.rsu_upgrade.requests.post") @patch("api.src.rsu_upgrade.pgquery.write_db") @patch( diff --git a/services/api/tests/src/test_rsuinfo.py b/services/api/tests/src/test_rsuinfo.py deleted file mode 100644 index 78655f54d..000000000 --- a/services/api/tests/src/test_rsuinfo.py +++ /dev/null @@ -1,101 +0,0 @@ -from unittest.mock import patch -import api.src.rsuinfo as rsuinfo -import api.tests.data.rsu_info_data as rsu_info_data -from api.tests.data import auth_data - -user_valid = auth_data.get_request_environ() - - -# ##################################### Testing Requests ########################################## -def test_request_options(): - info = rsuinfo.RsuInfo() - (body, code, headers) = info.options() - assert body == "" - assert code == 204 - assert headers["Access-Control-Allow-Methods"] == "GET" - - -@patch("api.src.rsuinfo.get_rsu_data") -def test_entry_get(mock_get_rsu_data): - mock_get_rsu_data.return_value = {"rsuList": []} - info = rsuinfo.RsuInfo() - (body, code, headers) = info.get() - - mock_get_rsu_data.assert_called_once() - assert code == 200 - assert headers["Access-Control-Allow-Origin"] == "test.com" - assert body == {"rsuList": []} - - -# ##################################### Testing Functions ########################################## -@patch("api.src.rsuinfo.pgquery") -def test_get_rsu_data_no_data(mock_pgquery): - mock_pgquery.query_db.return_value = {} - expected_rsu_data = {"rsuList": []} - expected_query = ( - "SELECT jsonb_build_object('type', 'Feature', 'id', row.rsu_id, 'geometry', ST_AsGeoJSON(row.geography)::jsonb, 'properties', to_jsonb(row)) " - "FROM (" - "SELECT rd.rsu_id, rd.geography, rd.milepost, rd.ipv4_address, rd.serial_number, rd.primary_route, rm.name AS model_name, man.name AS manufacturer_name " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rd.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - ") as row" - ) - actual_result = rsuinfo.get_rsu_data(user_valid, []) - mock_pgquery.query_db.assert_called_with(expected_query, params={}) - - assert actual_result == expected_rsu_data - - -@patch("api.src.rsuinfo.pgquery") -def test_get_rsu_data_single_result(mock_pgquery): - mock_pgquery.query_db.return_value = rsu_info_data.return_value_single_result - actual_result = rsuinfo.get_rsu_data(user_valid, []) - mock_pgquery.query_db.assert_called_once() - - assert actual_result == rsu_info_data.expected_rsu_data_single_result - - -@patch("api.src.rsuinfo.pgquery") -def test_get_rsu_data_multiple_result(mock_pgquery): - mock_pgquery.query_db.return_value = rsu_info_data.return_value_multiple_results - actual_result = rsuinfo.get_rsu_data(user_valid, []) - mock_pgquery.query_db.assert_called_once() - - assert actual_result == rsu_info_data.expected_rsu_data_multiple_results - - -# test that get_rsu_data is calling pgquery.query_db with expected arguments -@patch( - "common.pgquery.db_config", - new={"pool_size": 5, "max_overflow": 2, "pool_timeout": 30, "pool_recycle": 1800}, -) -@patch("common.pgquery.db", new=None) -@patch("rsuinfo.pgquery.query_db") -def test_get_rsu_data(mock_pgquery_query_db): - # mock return values for function dependencies - mock_pgquery_query_db.return_value = [[{"name": "Alice"}]] - - # call function - result = rsuinfo.get_rsu_data(user_valid, []) - - # check return value - expectedResult = {"rsuList": [{"name": "Alice"}]} - assert result == expectedResult - - expectedQuery = ( - "SELECT jsonb_build_object('type', 'Feature', 'id', row.rsu_id, 'geometry', ST_AsGeoJSON(row.geography)::jsonb, 'properties', to_jsonb(row)) " - "FROM (" - "SELECT rd.rsu_id, rd.geography, rd.milepost, rd.ipv4_address, rd.serial_number, rd.primary_route, rm.name AS model_name, man.name AS manufacturer_name " - "FROM public.rsus AS rd " - "JOIN public.rsu_organization_name AS ron_v ON ron_v.rsu_id = rd.rsu_id " - "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rd.model " - "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " - ") as row" - ) - # check that pgquery.query_db was called with expected arguments - rsuinfo.pgquery.query_db.assert_called_once_with(expectedQuery, params={}) - - -# TODO: add more tests here diff --git a/services/api/tests/src/test_smtp_error_handler.py b/services/api/tests/src/test_smtp_error_handler.py index 039233b18..f7bf9c0be 100644 --- a/services/api/tests/src/test_smtp_error_handler.py +++ b/services/api/tests/src/test_smtp_error_handler.py @@ -1,59 +1,15 @@ -from collections import namedtuple -import os -from unittest.mock import patch, MagicMock, call, mock_open -from api.src.smtp_error_handler import SMTP_SSLHandler +import sys +from unittest.mock import patch, MagicMock +from api.src.smtp_error_handler import ErrorEmailHandler import api.src.smtp_error_handler as smtp_error_handler -import api.tests.data.smtp_error_handler_data as smtp_error_handler_data -from unittest.mock import ANY -def test_get_environment_name_success(): - expected = "test" - actual = smtp_error_handler.get_environment_name("test:1234") - - assert actual == expected - - -def test_get_environment_name_fail(): - expected = "True" - actual = smtp_error_handler.get_environment_name(True) - - assert actual == str(expected) - - -###################################### Testing Functions ########################################## -@patch.dict( - os.environ, - {"CSM_EMAILS_TO_SEND_TO": "test@gmail.com,test2@gmail.com"}, - clear=True, -) -def test_get_subscribed_users_success(): - expected = ["test@gmail.com", "test2@gmail.com"] - actual = smtp_error_handler.get_subscribed_users() - assert actual == expected +LOGS_LINK = "http://logs_link.com" +IAPI_ENDPOINT = "http://test.test" +KC_SA_CLIENT_ID = "sa_cvmanager_python_api" +KC_SA_CLIENT_SECRET = "sa_cvmanager_python_api_secret" -EMAIL_TO_SEND_FROM = "test@test.test" -EMAIL_APP_USERNAME = "test" -EMAIL_APP_PASSWORD = "test" -DEFAULT_TARGET_SMTP_SERVER_ADDRESS = "smtp.gmail.com" -DEFAULT_TARGET_SMTP_SERVER_PORT = 587 -ENVIRONMENT_NAME = "ENVIRONMENT" -LOGS_LINK = "http://logs_link.com" -ERROR_EMAIL_UNSUBSCRIBE_LINK = "http://unsubscribe-{email}" - - -@patch.dict( - os.environ, - { - "CSM_TARGET_SMTP_SERVER_ADDRESS": DEFAULT_TARGET_SMTP_SERVER_ADDRESS, - "CSM_TARGET_SMTP_SERVER_PORT": str(DEFAULT_TARGET_SMTP_SERVER_PORT), - "CSM_EMAIL_TO_SEND_FROM": EMAIL_TO_SEND_FROM, - "CSM_EMAIL_APP_USERNAME": EMAIL_APP_USERNAME, - "CSM_EMAIL_APP_PASSWORD": EMAIL_APP_PASSWORD, - }, - clear=True, -) def test_configure_error_emails(): app = MagicMock() app.logger = MagicMock() @@ -62,60 +18,170 @@ def test_configure_error_emails(): app.logger.addHandler.assert_called_once() -@patch.dict( - os.environ, - { - "LOGS_LINK": LOGS_LINK, - "ENVIRONMENT_NAME": ENVIRONMENT_NAME, - "ERROR_EMAIL_UNSUBSCRIBE_LINK": ERROR_EMAIL_UNSUBSCRIBE_LINK, - }, - clear=True, -) -@patch("builtins.open", new_callable=mock_open, read_data="data") -@patch("api.src.smtp_error_handler.smtplib") -@patch("api.src.smtp_error_handler.get_subscribed_users") -def test_send(mock_get_subscribed_users, mock_smtplib, mock_file): - # prepare - emailHandler = SMTP_SSLHandler( - mailhost=[DEFAULT_TARGET_SMTP_SERVER_ADDRESS, DEFAULT_TARGET_SMTP_SERVER_PORT], - fromaddr=EMAIL_TO_SEND_FROM, - toaddrs=[], - subject="Automated CV Manager API Error", - credentials=[EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD], - secure=(), +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_with_asctime(): + # Test emit when record has asctime + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + + record = MagicMock() + record.asctime = "2023-09-15 00:00:00,000" + record.exc_text = "Test stack trace" + record.exc_info = None + record.getMessage.return_value = "Test error message" + + email_handler.emit(record) + + email_handler.email_api.send_api_error_email.assert_called_once_with( + error_message="Test error message", + stack_trace="Test stack trace", + timestamp="2023-09-15 00:00:00,000", + logs_link=LOGS_LINK, ) - smtp_obj = MagicMock() - mock_smtplib.SMTP = MagicMock() - mock_smtplib.SMTP.return_value = smtp_obj - - smtp_obj.starttls = MagicMock() - smtp_obj.ehlo = MagicMock() - smtp_obj.login = MagicMock() - smtp_obj.sendmail = MagicMock() - smtp_obj.quit = MagicMock() - mock_get_subscribed_users.return_value = ( - smtp_error_handler_data.subscribed_user_emails - ) - mock_file = MagicMock() - mock_file.read = MagicMock() - mock_file.read.return_value = smtp_error_handler_data.html_email_template - Record = namedtuple("Record", ["asctime"]) - record = Record("2023-09-15 00:00:00,000000") - emailHandler.format = lambda x: "%s".format(x) +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_without_asctime(): + # Test emit when record doesn't have asctime + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + + # Create a record without asctime + record = MagicMock() + record.getMessage = MagicMock(return_value="Test error message") + record.exc_text = "Test stack trace" + record.exc_info = None + # Remove asctime attribute + delattr(record, "asctime") + + with patch("datetime.datetime") as mock_datetime: + mock_datetime.now.return_value.strftime.return_value = ( + "2023-09-15 00:00:00,123456" + ) + + email_handler.emit(record) + + # Verify asctime was set + assert hasattr(record, "asctime") + assert record.asctime == "2023-09-15 00:00:00,123" + + email_handler.email_api.send_api_error_email.assert_called_once_with( + error_message="Test error message", + stack_trace="Test stack trace", + timestamp="2023-09-15 00:00:00,123", + logs_link=LOGS_LINK, + ) + + +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_without_stack_trace(): + # Test emit when record doesn't have exc_text + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + + record = MagicMock() + record.asctime = "2023-09-15 00:00:00,000" + record.exc_text = None + record.exc_info = None + record.getMessage.return_value = "Test error message" + + email_handler.emit(record) + + email_handler.email_api.send_api_error_email.assert_called_once_with( + error_message="Test error message", + stack_trace="No stack trace available", + timestamp="2023-09-15 00:00:00,000", + logs_link=LOGS_LINK, + ) - # execute - emailHandler.emit(record) - # assert - mock_get_subscribed_users.assert_called_once() - smtp_obj.starttls.assert_called_once() - smtp_obj.ehlo.assert_called_once() - smtp_obj.login.assert_called_once_with(EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD) +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_with_newlines(): + # Test that newlines are converted to
tags + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + + record = MagicMock() + record.asctime = "2023-09-15 00:00:00,000" + record.exc_text = "Line 1\nLine 2\nLine 3" + record.exc_info = None + record.getMessage.return_value = "Error line 1\nError line 2" + + email_handler.emit(record) + + email_handler.email_api.send_api_error_email.assert_called_once_with( + error_message="Error line 1\nError line 2", + stack_trace="Line 1\nLine 2\nLine 3", + timestamp="2023-09-15 00:00:00,000", + logs_link=LOGS_LINK, + ) - smtp_obj.sendmail.call_count == 2 - smtp_obj.sendmail.assert_any_call(EMAIL_TO_SEND_FROM, "test1@gmail.com", ANY) - smtp_obj.sendmail.assert_any_call(EMAIL_TO_SEND_FROM, "test2@gmail.com", ANY) - smtp_obj.quit.call_count == 2 +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_handles_exception(): + # Test that exceptions in emit are handled + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + email_handler.email_api.send_api_error_email.side_effect = Exception("API Error") + email_handler.handleError = MagicMock() + + record = MagicMock() + record.asctime = "2023-09-15 00:00:00,000" + record.exc_text = "Test stack trace" + record.exc_info = None + record.getMessage.return_value = "Test error message" + + email_handler.emit(record) + + # Verify handleError was called when exception occurred + email_handler.handleError.assert_called_once_with(record) + + +@patch("api_environment.LOGS_LINK", LOGS_LINK) +@patch("api_environment.IAPI_ENDPOINT", IAPI_ENDPOINT) +@patch("api_environment.KC_SA_CLIENT_ID", KC_SA_CLIENT_ID) +@patch("api_environment.KC_SA_CLIENT_SECRET", KC_SA_CLIENT_SECRET) +def test_emit_with_real_exception(): + """Test emit with a real exception and traceback (exc_info)""" + email_handler = ErrorEmailHandler() + email_handler.email_api = MagicMock() + + # Create a real exception with traceback + try: + raise ValueError("Test exception") + except ValueError: + exc_info = sys.exc_info() + + record = MagicMock() + record.asctime = "2023-09-15 00:00:00,000" + record.exc_info = exc_info + record.exc_text = None + record.getMessage.return_value = "ValueError occurred" + + email_handler.emit(record) + + # Verify the email was sent + assert email_handler.email_api.send_api_error_email.called + call_args = email_handler.email_api.send_api_error_email.call_args[1] + + # Check that stack trace contains expected elements + assert "ValueError: Test exception" in call_args["stack_trace"] + assert "raise ValueError" in call_args["stack_trace"] + assert "\n" in call_args["stack_trace"] # Newlines preserved + assert call_args["error_message"] == "ValueError occurred" + assert call_args["timestamp"] == "2023-09-15 00:00:00,000" diff --git a/services/api/tests/src/test_userauth.py b/services/api/tests/src/test_userauth.py deleted file mode 100644 index d59d70af6..000000000 --- a/services/api/tests/src/test_userauth.py +++ /dev/null @@ -1,65 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from api.src import userauth -from common.auth_tools import ENVIRON_USER_KEY, EnvironNoAuth -from werkzeug.exceptions import Unauthorized -from api.tests.data import auth_data - - -user_valid = auth_data.get_request_environ() - - -@patch("api.src.userauth.Resource", new=MagicMock()) -def test_rga_options(): - expected_options = { - "Access-Control-Allow-Origin": "test.com", - "Access-Control-Allow-Headers": "Content-Type,Authorization", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Max-Age": "3600", - } - # instantiate UserAuth - rga = userauth.UserAuth() - - # call options() - result = rga.options() - - # check result - assert result == ("", 204, expected_options) - - -@patch("api.src.userauth.Resource", new=MagicMock()) -def test_rga_get(): - expected_headers = { - "Access-Control-Allow-Origin": "test.com", - "Content-Type": "application/json", - } - result = userauth.UserAuth().get() - - # check result - assert result == ( - { - "email": "test@gmail.com", - "organizations": [ - {"name": "Test Org", "role": "admin"}, - {"name": "Test Org 2", "role": "operator"}, - {"name": "Test Org 3", "role": "user"}, - ], - "super_user": True, - "first_name": "Test", - "last_name": "User", - "name": "Test User", - }, - 200, - expected_headers, - ) - - -def test_rga_get_unauthorized_user(): - req = MagicMock() - req.environ = {ENVIRON_USER_KEY: EnvironNoAuth()} - with patch("common.auth_tools.request", req): - with pytest.raises(Unauthorized) as exc_info: - userauth.UserAuth().get() - - assert str(exc_info.value) == "401 Unauthorized: User is not authenticated" diff --git a/services/api/tests/src/test_wzdx_feed.py b/services/api/tests/src/test_wzdx_feed.py index 9a66311f6..6329fb511 100644 --- a/services/api/tests/src/test_wzdx_feed.py +++ b/services/api/tests/src/test_wzdx_feed.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock, Mock + +from mock import patch from api.src import wzdx_feed -import os +@patch("api_environment.WZDX_ENDPOINT", "myendpoint") +@patch("api_environment.WZDX_API_KEY", "myapikey") # test that get_wzdx_data is calling json.loads with expected arguments def test_get_wzdx_data(): # mock return values for function dependencies @@ -14,12 +17,6 @@ def test_get_wzdx_data(): ) ) - endpoint = "my_endpoint" - api_key = "my_api_key" - - os.environ["WZDX_ENDPOINT"] = endpoint - os.environ["WZDX_API_KEY"] = api_key - # call function result = wzdx_feed.get_wzdx_data() diff --git a/services/common/auth_tools.py b/services/common/auth_tools.py index df07b06c8..3631e7d30 100644 --- a/services/common/auth_tools.py +++ b/services/common/auth_tools.py @@ -296,16 +296,21 @@ def enforce_organization_restrictions( Forbidden: If the user attempts to modify organizations they are not authorized to modify. """ if not user.user_info.super_user: - for key in keys_to_check: - # Collect list of organizations the user doesn't have enough permissions to modify - unqualified_orgs = [ - org for org in spec.get(key, []) if org not in qualified_orgs - ] - # If the user tries to add organizations they are not authorized for, raise Forbidden - if unqualified_orgs: - raise Forbidden( - f"Unauthorized organization modification through {key}: {','.join(unqualified_orgs)}" - ) + try: + for key in keys_to_check: + # Collect list of organizations the user doesn't have enough permissions to modify + unqualified_orgs = [ + org.get("name") + for org in spec.get(key, []) + if org.get("name") not in qualified_orgs + ] + # If the user tries to add organizations they are not authorized for, raise Forbidden + if unqualified_orgs: + raise Forbidden( + f"Unauthorized organization modification through {key}: {','.join(unqualified_orgs)}" + ) + except Exception as e: + logging.error("Failed to verify organization permissions", e) class PermissionResult: diff --git a/services/common/common_environment.py b/services/common/common_environment.py new file mode 100644 index 000000000..6b80e65d3 --- /dev/null +++ b/services/common/common_environment.py @@ -0,0 +1,46 @@ +import os +import logging + + +def configure_logging() -> str: + LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", "INFO") + if not LOGGING_LEVEL or LOGGING_LEVEL == "": + LOGGING_LEVEL = "INFO" + logging.basicConfig(format="%(levelname)s:%(message)s", level=LOGGING_LEVEL) + return LOGGING_LEVEL + + +def get_env_var( + key: str, default: str | None = None, error=False, warn=True, secret=False +): + value = os.environ.get(key) + if value is None or value == "": + if error: + raise Exception(f"Missing required environment variable: {key}") + if warn: + logging.warning( + f"Missing environment variable: {key}, using default: {default}" + ) + else: + logging.info( + f"Environment variable {key} was not specified, using default: {default}" + ) + return default + if secret: + logging.info(f"Environment variable {key} is set") + else: + logging.info(f"Environment variable {key} is set to {value}") + return value + + +LOGGING_LEVEL = configure_logging() +TIMEZONE = get_env_var("TIMEZONE", "America/Denver") + +GCP_PROJECT = get_env_var("GCP_PROJECT") +BLOB_STORAGE_BUCKET = get_env_var("BLOB_STORAGE_BUCKET") + +PG_DB_HOST = get_env_var("PG_DB_HOST") +PG_DB_USER = get_env_var("PG_DB_USER") +PG_DB_PASS = get_env_var("PG_DB_PASS") +PG_DB_NAME = get_env_var("PG_DB_NAME") +INSTANCE_CONNECTION_NAME = get_env_var("INSTANCE_CONNECTION_NAME", "").strip() diff --git a/services/common/emailSender.py b/services/common/emailSender.py index e1cbf4eab..0e61ad7e5 100644 --- a/services/common/emailSender.py +++ b/services/common/emailSender.py @@ -22,8 +22,8 @@ def send( username, password, pretty=False, - tlsEnabled="true", - authEnabled="true", + tlsEnabled=True, + authEnabled=True, ): try: # prepare email @@ -33,10 +33,10 @@ def send( else: toSend = self.format(recipient, subject, message, replyEmail) - if tlsEnabled == "true": + if tlsEnabled: self.server.starttls(context=self.context) # start TLS encryption self.server.ehlo() # say hello - if authEnabled == "true": + if authEnabled: self.server.login(username, password) # send email diff --git a/services/common/email_api.py b/services/common/email_api.py new file mode 100644 index 000000000..ef4fbfa1e --- /dev/null +++ b/services/common/email_api.py @@ -0,0 +1,155 @@ +import logging +import datetime +import requests +from common.keycloak_api import KeycloakServiceAccountApi + + +class EmailApi: + def __init__(self, iapi_base_url, kc_api: KeycloakServiceAccountApi): + """ + Initialize the EmailApi with the base URL and Keycloak service account API. + + Args: + iapi_base_url (str): The base URL for the email API. + kc_api (KeycloakServiceAccountApi): The Keycloak service account API + used to obtain authentication tokens for email API requests. + """ + self.iapi_endpoint = iapi_base_url + self.kc_api = kc_api + + def _build_response(self, response: requests.Response) -> tuple[int, dict]: + if not (200 <= response.status_code < 300): + logging.error( + f"Email API request failed: {response.status_code} - {response.text}" + ) + try: + response_data = response.json() + if isinstance(response_data, dict): + return response.status_code, response_data + return response.status_code, {"data": response_data} + except ValueError: + return response.status_code, {"error": response.text} + + def _handle_request_exception( + self, exc: requests.RequestException + ) -> tuple[int, dict]: + logging.exception("Email API request failed: %s", exc) + return 500, {"error": str(exc)} + + def send_message_counts( + self, + org_name: str, + deployment_title: str, + start_date: datetime.datetime, + end_date: datetime.datetime, + message_type_list: list[str], + rsu_counts: list[dict], + ) -> tuple[int, dict]: + """ + Send a message counts email via the API. + + Args: + org_name (str): Organization name. + deployment_title (str): Deployment title. + start_date (datetime.datetime): Start date. + end_date (datetime.datetime): End date. + message_type_list (list[str]): List of message types. + rsu_counts (list[dict]): List of count dictionaries. + + Returns: + tuple[int, dict]: The HTTP status code and the response JSON. + """ + token = self.kc_api.get_kc_token() + if not token: + return 500, {"error": "Unable to obtain Keycloak token."} + try: + response = requests.post( + f"{self.iapi_endpoint}/emails/message-counts", + headers={"Authorization": f"Bearer {token['access_token']}"}, + json={ + "org_name": org_name, + "deployment_title": deployment_title, + "start_date": start_date.timestamp(), + "end_date": end_date.timestamp(), + "message_type_list": message_type_list, + "rsu_counts": rsu_counts, + }, + timeout=10, + ) + except requests.RequestException as exc: + return self._handle_request_exception(exc) + return self._build_response(response) + + def send_firmware_upgrade_failure( + self, rsu_ip: str, error_message: str, failure_type: str, stack_trace: str + ) -> tuple[int, dict]: + """ + Send a firmware upgrade failure email via the API. + + Args: + rsu_ip (str): RSU IP address. + error_message (str): Error message. + failure_type (str): Type of failure. + stack_trace (str): Stack trace. + + Returns: + tuple[int, str]: The HTTP status code and the response JSON. + """ + token = self.kc_api.get_kc_token() + if not token: + return 500, {"error": "Unable to obtain Keycloak token."} + + try: + response = requests.post( + f"{self.iapi_endpoint}/emails/firmware-upgrade-failures", + headers={"Authorization": f"Bearer {token['access_token']}"}, + json={ + "rsu_ip": rsu_ip, + "message": error_message, + "failure_type": failure_type, + "stack_trace": stack_trace, + }, + timeout=10, + ) + except requests.RequestException as exc: + return self._handle_request_exception(exc) + return self._build_response(response) + + def send_api_error_email( + self, + error_message: str, + stack_trace: str, + timestamp: str, + logs_link: str, + ) -> tuple[int, dict]: + """ + Send a critical api error email via the API. + + Args: + error_message (str): Error message. + stack_trace (str): Stack trace. + timestamp (str): Timestamp of the error in ISO format. + logs_link (str): Link to the logs. + + Returns: + tuple[int, str]: The HTTP status code and the response JSON. + """ + token = self.kc_api.get_kc_token() + if not token: + return 500, {"error": "Unable to obtain Keycloak token."} + + try: + response = requests.post( + f"{self.iapi_endpoint}/emails/api-errors", + headers={"Authorization": f"Bearer {token['access_token']}"}, + json={ + "error_message": error_message, + "stack_trace": stack_trace, + "timestamp": timestamp, + "logs_link": logs_link, + }, + timeout=10, + ) + except requests.RequestException as exc: + return self._handle_request_exception(exc) + return self._build_response(response) diff --git a/services/common/gcs_utils.py b/services/common/gcs_utils.py index d7f14bd94..443c3ca5c 100644 --- a/services/common/gcs_utils.py +++ b/services/common/gcs_utils.py @@ -1,9 +1,9 @@ from google.cloud import storage from common.util import validate_file_type import logging +from common import common_environment import os - def download_gcp_blob(blob_name, destination_file_name, file_extension=None): """Download a file from a GCP Bucket Storage bucket to a local file. @@ -18,8 +18,8 @@ def download_gcp_blob(blob_name, destination_file_name, file_extension=None): if not validate_file_type(blob_name, file_extension): return False - gcp_project = os.environ.get("GCP_PROJECT") - bucket_name = os.environ.get("BLOB_STORAGE_BUCKET") + gcp_project = common_environment.GCP_PROJECT + bucket_name = common_environment.BLOB_STORAGE_BUCKET storage_client = storage.Client(gcp_project) bucket = storage_client.get_bucket(bucket_name) blob = bucket.blob(blob_name) @@ -35,8 +35,8 @@ def download_gcp_blob(blob_name, destination_file_name, file_extension=None): def list_gcs_blobs(gcs_prefix, file_extension): files = [] - gcp_project = os.environ.get("GCP_PROJECT") - bucket_name = os.environ.get("BLOB_STORAGE_BUCKET") + gcp_project = common_environment.GCP_PROJECT + bucket_name = common_environment.BLOB_STORAGE_BUCKET logging.debug(f"Listing blobs in bucket {bucket_name} with prefix {gcs_prefix}.") storage_client = storage.Client(gcp_project) blobs = storage_client.list_blobs(bucket_name, prefix=gcs_prefix, delimiter="/") diff --git a/services/common/keycloak_api.py b/services/common/keycloak_api.py new file mode 100644 index 000000000..c49da6a90 --- /dev/null +++ b/services/common/keycloak_api.py @@ -0,0 +1,157 @@ +import logging +import datetime +from typing import TypedDict +from keycloak import KeycloakOpenID + + +class KeycloakToken(TypedDict): + access_token: str + expires_in: int + refresh_expires_in: int + refresh_token: str + token_type: str + id_token: str + not_before_policy: str + session_state: str + scope: str + + +class KeycloakServiceAccountApi: + def __init__(self, endpoint, realm, client_id, client_secret): + """ + Initialize the KeycloakServiceAccountApi with the base URL, client ID, and client secret. + + Args: + endpoint (str): The Keycloak server URL. + realm (str): The Keycloak realm name. + client_id (str): The Keycloak client ID for authentication. + client_secret (str): The Keycloak client secret for authentication. + """ + self.endpoint = endpoint + self.realm = realm + self.client_id = client_id + self.client_secret = client_secret + self.keycloak_openid = KeycloakOpenID( + server_url=endpoint, + realm_name=realm, + client_id=client_id, + client_secret_key=client_secret, + ) + + self.token: KeycloakToken | None = None + self.token_expiration_date: datetime.datetime | None = None + self.refresh_token_expiration_date: datetime.datetime | None = None + + def _gen_keycloak_token(self) -> KeycloakToken | None: + """ + Request a new Keycloak token from the authentication endpoint. + + Returns: + KeycloakToken | None: The token dictionary, or None if generation fails. + """ + try: + return self.keycloak_openid.token( + grant_type="client_credentials", + client_id=self.client_id, + client_secret=self.client_secret, + scope="openid", + ) + except Exception as e: + logging.warning(f"Failed to generate Keycloak token: {e}") + return None + + def _refresh_keycloak_token(self, refresh_token: str) -> KeycloakToken | None: + """ + Refresh an existing Keycloak token using the refresh token. + + Args: + refresh_token (str): The refresh token to use for obtaining a new access token. + + Returns: + KeycloakToken | None: The refreshed token dictionary, or None if refresh fails. + """ + try: + return self.keycloak_openid.refresh_token(refresh_token) + except Exception as e: + logging.warning(f"Failed to refresh Keycloak token: {e}") + return None + + def _is_current_token_valid(self) -> bool: + """ + Check if the current Keycloak token is valid (not expired). + + Returns: + bool: True if the token exists and is not expired, False otherwise. + """ + return ( + self.token is not None + and self.token_expiration_date is not None + and datetime.datetime.now() < self.token_expiration_date + ) + + def _is_refresh_token_valid(self) -> bool: + """ + Check if the refresh token is still valid (not expired). + + Returns: + bool: True if the refresh token exists and is not expired, False otherwise. + """ + return ( + self.token is not None + and self.refresh_token_expiration_date is not None + and datetime.datetime.now() < self.refresh_token_expiration_date + ) + + def get_kc_token(self) -> KeycloakToken | None: + """ + Get a valid Keycloak token, refreshing or regenerating it if necessary. + + Returns: + KeycloakToken | None: The valid token dictionary, or None if unable to obtain one. + """ + # If current token is valid, return it + if self._is_current_token_valid(): + return self.token + + # If access token expired but refresh token is still valid, try to refresh + if self.token is not None and self._is_refresh_token_valid(): + logging.info("Access token expired. Attempting to refresh token.") + refreshed_token = self._refresh_keycloak_token(self.token["refresh_token"]) + if refreshed_token: + self._update_token(refreshed_token) + logging.info("Successfully refreshed Keycloak token.") + return self.token + else: + logging.warning("Token refresh failed. Generating new token.") + + # If no token exists, or refresh failed, generate a new token + logging.info("Generating new Keycloak token.") + new_token = self._gen_keycloak_token() + if new_token: + self._update_token(new_token) + logging.info("Successfully generated new Keycloak token.") + else: + logging.error("Failed to obtain Keycloak token.") + return None + + return self.token + + def _update_token(self, token: KeycloakToken) -> None: + """ + Update the stored token and calculate expiration timestamps. + + Args: + token (KeycloakToken): The new token to store. + """ + self.token = token + current_time = datetime.datetime.now() + + # expires_in is in seconds, convert to timedelta + self.token_expiration_date = current_time + datetime.timedelta( + seconds=token["expires_in"] + ) + + # refresh_expires_in is also in seconds + self.refresh_token_expiration_date = current_time + datetime.timedelta( + seconds=token["refresh_expires_in"] + ) diff --git a/services/common/pgquery.py b/services/common/pgquery.py index 40c9e02f6..51e6d20f4 100644 --- a/services/common/pgquery.py +++ b/services/common/pgquery.py @@ -1,6 +1,6 @@ -import os import sqlalchemy import logging +from common import common_environment db_config = { # Pool size is the maximum number of permanent connections to keep. @@ -60,22 +60,18 @@ def init_socket_connection_engine(db_user, db_pass, db_name, unix_query): def init_connection_engine(): - db_user = os.environ["PG_DB_USER"] - db_pass = os.environ["PG_DB_PASS"] - db_name = os.environ["PG_DB_NAME"] - if ( - "INSTANCE_CONNECTION_NAME" in os.environ - and os.environ["INSTANCE_CONNECTION_NAME"].strip() - ): + db_user = common_environment.PG_DB_USER + db_pass = common_environment.PG_DB_PASS + db_name = common_environment.PG_DB_NAME + if common_environment.INSTANCE_CONNECTION_NAME: logging.debug("Using socket connection") - instance_connection_name = os.environ["INSTANCE_CONNECTION_NAME"] unix_query = { - "unix_sock": f"/cloudsql/{instance_connection_name}/.s.PGSQL.5432" + "unix_sock": f"/cloudsql/{common_environment.INSTANCE_CONNECTION_NAME}/.s.PGSQL.5432" } return init_socket_connection_engine(db_user, db_pass, db_name, unix_query) else: logging.debug("Using tcp connection") - db_host = os.environ["PG_DB_HOST"] + db_host = common_environment.PG_DB_HOST # Extract host and port from db_host host_args = db_host.split(":") db_hostname, db_port = host_args[0], int(host_args[1]) diff --git a/services/common/snmp/rsu_message_forward_helpers.py b/services/common/snmp/rsu_message_forward_helpers.py index de6f6bcf3..5d2ac829b 100644 --- a/services/common/snmp/rsu_message_forward_helpers.py +++ b/services/common/snmp/rsu_message_forward_helpers.py @@ -1,3 +1,7 @@ +import common.util as util +import logging +from enum import Enum + # Delta is in years def hex_datetime(now, delta=0): """ @@ -132,3 +136,65 @@ def startend_ntcip1218(val): minute = "0" + minute if len(minute) == 1 else minute # Return the processed datetime string return f"{year}-{month}-{day} {hour}:{minute}" + + +class MsgFwdType(Enum): + DSRC = "rsuDsrcFwd" + RECEIVED = "rsuReceivedMsg" + XMIT = "rsuXmitMsgFwding" + +class TableNames(Enum): + RECEIVED = "rsuReceivedMsgTable" + XMIT = "rsuXmitMsgFwdingTable" + +def format_snmp_msgfwd_configs(config_list, rsu_ip=None): + """ + Formats and organizes SNMP message forwarding configurations into a structured dictionary format. + + This function processes a list of configuration rows, restructures them into a dictionary + compatible with the SNMP walk response format, and classifies the configurations based on their + message forwarding types. It handles specific message forwarding types such as DSRC, RECEIVED, + and XMIT, ensuring that related objects (RX and TX) are available for complete NTCIP 1218 + configuration support. Unknown message forwarding types are logged with a warning. + + :param config_list: A list of configuration dictionaries where each dictionary represents data + for a single configuration row. + :type config_list: list[dict] + :param rsu_ip: Optional IPv4 address of the RSU, used for logging purposes. Defaults to None. + :type rsu_ip: str | None + :return: A dictionary containing organized SNMP message forwarding configurations. + :rtype: dict + """ + msgfwd_configs_dict = {} + for row in config_list: + config_row = { + "Message Type": row["message_type"].upper(), + "IP": row["dest_ipv4"], + "Port": row["dest_port"], + "Start DateTime": util.format_date_denver_iso(row["start_datetime"]), + "End DateTime": util.format_date_denver_iso(row["end_datetime"]), + "Config Active": active(row["active"]), + "Full WSMP": active(row["security"]), + } + + # Based on the value of msgfwd_type, store the configuration data to match the response object of rsufwdsnmpwalk + msgfwd_type_value = row["msgfwd_type"] + if msgfwd_type_value.upper() == MsgFwdType.DSRC.value.upper(): + msgfwd_configs_dict[row["snmp_index"]] = config_row + elif msgfwd_type_value.upper() == MsgFwdType.RECEIVED.value.upper(): + msgfwd_configs_dict.setdefault(TableNames.RECEIVED.value, {})[row["snmp_index"]] = config_row + elif msgfwd_type_value.upper() == MsgFwdType.XMIT.value.upper(): + msgfwd_configs_dict.setdefault(TableNames.XMIT.value, {})[row["snmp_index"]] = config_row + else: + rsu_info = f" for RSU '{rsu_ip}'" if rsu_ip else "" + logging.warning( + f"Encountered unknown message forwarding configuration type '{msgfwd_type_value}'{rsu_info}" + ) + + # Make sure both RX and TX objects are available if the RSU ends up having NTCIP 1218 configurations + if TableNames.RECEIVED.value in msgfwd_configs_dict and TableNames.XMIT.value not in msgfwd_configs_dict: + msgfwd_configs_dict[TableNames.XMIT.value] = {} + elif TableNames.XMIT.value in msgfwd_configs_dict and TableNames.RECEIVED.value not in msgfwd_configs_dict: + msgfwd_configs_dict[TableNames.RECEIVED.value] = {} + + return {"RsuFwdSnmpwalk": msgfwd_configs_dict} diff --git a/services/common/tests/snmp/test_rsu_message_forward_helpers.py b/services/common/tests/snmp/test_rsu_message_forward_helpers.py new file mode 100644 index 000000000..13b992862 --- /dev/null +++ b/services/common/tests/snmp/test_rsu_message_forward_helpers.py @@ -0,0 +1,265 @@ +import datetime +import pytest +from unittest.mock import patch +from common.snmp.rsu_message_forward_helpers import ( + hex_datetime, + message_type_rsu41, + message_type_ntcip1218, + ip_rsu41, + ip_ntcip1218, + protocol, + rssi_ntcip1218, + fwdon, + active, + startend_rsu41, + startend_ntcip1218, + format_snmp_msgfwd_configs, + TableNames, +) + +def test_hex_datetime(): + now = datetime.datetime(2023, 10, 27, 10, 30) + # 2023 -> 07e7, 10 -> 0a, 27 -> 1b, 10 -> 0a, 30 -> 1e + assert hex_datetime(now) == "07e70a1b0a1e" + # With delta=1: 2024 -> 07e8 + assert hex_datetime(now, delta=1) == "07e80a1b0a1e" + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ('" "', "BSM"), + ("00 00 00 20", "BSM"), + ("00 00 80 02", "SPaT"), + ("80 02", "SPaT"), + ("00 00 80 03", "TIM"), + ("E0 00 00 17", "MAP"), + ("E0 00 00 15", "SSM"), + ("E0 00 00 16", "SRM"), + ("unknown", "Other"), + ], +) +def test_message_type_rsu41(raw_value, expected): + assert message_type_rsu41(raw_value) == expected + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ("20000000", "BSM"), + ("80020000", "SPaT"), + ("80030000", "TIM"), + ("E0000017", "MAP"), + ("e0000017", "MAP"), + ("E0000015", "SSM"), + ("E0000016", "SRM"), + ("unknown", "Other"), + ], +) +def test_message_type_ntcip1218(raw_value, expected): + assert message_type_ntcip1218(raw_value) == expected + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ("C0 A8 01 01", "192.168.1.1"), + # It takes the last 4 components + ("00 00 00 00 C0 A8 01 02", "192.168.1.2"), + ], +) +def test_ip_rsu41(raw_value, expected): + assert ip_rsu41(raw_value) == expected + +def test_ip_ntcip1218(): + assert ip_ntcip1218(" 192.168.1.1 ") == "192.168.1.1" + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ("1", "TCP"), + ("tcp(1)", "TCP"), + ("2", "UDP"), + ("udp(2)", "UDP"), + ("3", "Other"), + ], +) +def test_protocol(raw_value, expected): + assert protocol(raw_value) == expected + +def test_rssi_ntcip1218(): + assert rssi_ntcip1218("-70 dBm") == -70 + +def test_fwdon(): + assert fwdon("1") == "On" + assert fwdon("0") == "Off" + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ("1", "Enabled"), + ("4", "Enabled"), + ("active(1)", "Enabled"), + ("2", "Disabled"), + ], +) +def test_active(raw_value, expected): + assert active(raw_value) == expected + +@pytest.mark.parametrize( + "raw_value, expected", + [ + # 2023-10-27 10:30 + # 2023 -> 07 E7, 10 -> 0A, 27 -> 1B, 10 -> 0A, 30 -> 1E + ("07 E7 0A 1B 0A 1E", "2023-10-27 10:30"), + # Padding check: 2023-01-02 03:04 + ("07 E7 01 02 03 04", "2023-01-02 03:04"), + ], +) +def test_startend_rsu41(raw_value, expected): + assert startend_rsu41(raw_value) == expected + +@pytest.mark.parametrize( + "raw_value, expected", + [ + ("2023-10-27,10:30", "2023-10-27 10:30"), + # Padding check + ("2023-1-2,3:4", "2023-01-02 03:04"), + ], +) +def test_startend_ntcip1218(raw_value, expected): + assert startend_ntcip1218(raw_value) == expected + +@patch("common.util.format_date_denver_iso") +def test_format_snmp_msgfwd_configs_dsrc(mock_format_date): + mock_format_date.side_effect = lambda x: x # Just return the input for simplicity + + config_list = [ + { + "message_type": "bsm", + "dest_ipv4": "192.168.1.1", + "dest_port": "1234", + "start_datetime": "2023-01-01T00:00:00", + "end_datetime": "2023-12-31T23:59:59", + "active": "1", + "security": "1", + "msgfwd_type": "rsuDsrcFwd", + "snmp_index": "1" + } + ] + + expected = { + "RsuFwdSnmpwalk": { + "1": { + "Message Type": "BSM", + "IP": "192.168.1.1", + "Port": "1234", + "Start DateTime": "2023-01-01T00:00:00", + "End DateTime": "2023-12-31T23:59:59", + "Config Active": "Enabled", + "Full WSMP": "Enabled", + } + } + } + + result = format_snmp_msgfwd_configs(config_list) + assert result == expected + +@patch("common.util.format_date_denver_iso") +def test_format_snmp_msgfwd_configs_ntcip(mock_format_date): + mock_format_date.side_effect = lambda x: x + + config_list = [ + { + "message_type": "spat", + "dest_ipv4": "192.168.1.2", + "dest_port": "5678", + "start_datetime": "2023-01-01T00:00:00", + "end_datetime": "2023-12-31T23:59:59", + "active": "4", + "security": "0", + "msgfwd_type": "rsuReceivedMsg", + "snmp_index": "2" + }, + { + "message_type": "map", + "dest_ipv4": "192.168.1.3", + "dest_port": "9012", + "start_datetime": "2023-01-01T00:00:00", + "end_datetime": "2023-12-31T23:59:59", + "active": "1", + "security": "1", + "msgfwd_type": "rsuXmitMsgFwding", + "snmp_index": "3" + } + ] + + result = format_snmp_msgfwd_configs(config_list) + + assert TableNames.RECEIVED.value in result["RsuFwdSnmpwalk"] + assert TableNames.XMIT.value in result["RsuFwdSnmpwalk"] + assert result["RsuFwdSnmpwalk"][TableNames.RECEIVED.value]["2"]["Message Type"] == "SPAT" + assert result["RsuFwdSnmpwalk"][TableNames.XMIT.value]["3"]["Message Type"] == "MAP" + +def test_format_snmp_msgfwd_configs_balancing(): + # Only RECEIVED + config_list = [{ + "message_type": "bsm", "dest_ipv4": "ip", "dest_port": "port", + "start_datetime": "start", "end_datetime": "end", + "active": "1", "security": "0", "msgfwd_type": "rsuReceivedMsg", "snmp_index": "1" + }] + with patch("common.util.format_date_denver_iso", side_effect=lambda x: x): + result = format_snmp_msgfwd_configs(config_list) + assert TableNames.RECEIVED.value in result["RsuFwdSnmpwalk"] + assert TableNames.XMIT.value in result["RsuFwdSnmpwalk"] + assert result["RsuFwdSnmpwalk"][TableNames.XMIT.value] == {} + + # Only XMIT + config_list = [{ + "message_type": "bsm", "dest_ipv4": "ip", "dest_port": "port", + "start_datetime": "start", "end_datetime": "end", + "active": "1", "security": "0", "msgfwd_type": "rsuXmitMsgFwding", "snmp_index": "1" + }] + with patch("common.util.format_date_denver_iso", side_effect=lambda x: x): + result = format_snmp_msgfwd_configs(config_list) + assert TableNames.RECEIVED.value in result["RsuFwdSnmpwalk"] + assert TableNames.XMIT.value in result["RsuFwdSnmpwalk"] + assert result["RsuFwdSnmpwalk"][TableNames.RECEIVED.value] == {} + +def test_format_snmp_msgfwd_configs_unknown_type(): + config_list = [{ + "message_type": "bsm", "dest_ipv4": "ip", "dest_port": "port", + "start_datetime": "start", "end_datetime": "end", + "active": "1", "security": "0", "msgfwd_type": "unknown", "snmp_index": "1" + }] + with patch("common.util.format_date_denver_iso", side_effect=lambda x: x): + result = format_snmp_msgfwd_configs(config_list, rsu_ip="1.1.1.1") + + assert result["RsuFwdSnmpwalk"] == {} + +def test_format_snmp_msgfwd_configs_case_insensitivity(): + config_list = [ + { + "message_type": "bsm", "dest_ipv4": "192.168.1.1", "dest_port": "1234", + "start_datetime": "start", "end_datetime": "end", + "active": "1", "security": "1", "msgfwd_type": "RSUdsrCFWD", "snmp_index": "1" + }, + { + "message_type": "spat", "dest_ipv4": "192.168.1.2", "dest_port": "5678", + "start_datetime": "start", "end_datetime": "end", + "active": "4", "security": "0", "msgfwd_type": "RsuReceiveDMsG", "snmp_index": "2" + }, + { + "message_type": "map", "dest_ipv4": "192.168.1.3", "dest_port": "9012", + "start_datetime": "start", "end_datetime": "end", + "active": "1", "security": "1", "msgfwd_type": "rsuxmitmsgfwding", "snmp_index": "3" + } + ] + with patch("common.util.format_date_denver_iso", side_effect=lambda x: x): + result = format_snmp_msgfwd_configs(config_list) + + # Check DSRC + assert "1" in result["RsuFwdSnmpwalk"] + # Check RECEIVED + assert TableNames.RECEIVED.value in result["RsuFwdSnmpwalk"] + assert "2" in result["RsuFwdSnmpwalk"][TableNames.RECEIVED.value] + # Check XMIT + assert TableNames.XMIT.value in result["RsuFwdSnmpwalk"] + assert "3" in result["RsuFwdSnmpwalk"][TableNames.XMIT.value] diff --git a/services/common/tests/test_emailSender.py b/services/common/tests/test_emailSender.py index 0677f5f27..b2e5a4ed7 100644 --- a/services/common/tests/test_emailSender.py +++ b/services/common/tests/test_emailSender.py @@ -35,8 +35,8 @@ def test_send_with_tls_and_auth(): EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD, False, - "true", # tlsEnabled - "true", # authEnabled + True, # tlsEnabled + True, # authEnabled ) # assert @@ -69,8 +69,8 @@ def test_send_with_tls_and_no_auth(): EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD, False, - "true", # tlsEnabled - "false", # authEnabled + True, # tlsEnabled + False, # authEnabled ) # assert @@ -103,8 +103,8 @@ def test_send_with_no_tls_and_auth(): EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD, False, - "false", # tlsEnabled - "true", # authEnabled + False, # tlsEnabled + True, # authEnabled ) # assert @@ -137,8 +137,8 @@ def test_send_with_no_tls_and_no_auth(): EMAIL_APP_USERNAME, EMAIL_APP_PASSWORD, False, - "false", # tlsEnabled - "false", # authEnabled + False, # tlsEnabled + False, # authEnabled ) # assert @@ -146,4 +146,4 @@ def test_send_with_no_tls_and_no_auth(): emailSender.server.ehlo.assert_not_called() emailSender.server.login.assert_not_called() emailSender.server.sendmail.assert_called_once() - emailSender.server.quit.assert_called_once() \ No newline at end of file + emailSender.server.quit.assert_called_once() diff --git a/services/common/tests/test_email_api.py b/services/common/tests/test_email_api.py new file mode 100644 index 000000000..6e843dd2f --- /dev/null +++ b/services/common/tests/test_email_api.py @@ -0,0 +1,444 @@ +import pytest +import datetime +from unittest.mock import Mock, patch +from common.email_api import EmailApi +from common.keycloak_api import KeycloakServiceAccountApi + + +@pytest.fixture +def mock_kc_api(): + """Fixture to create a mock KeycloakServiceAccountApi instance.""" + mock = Mock(spec=KeycloakServiceAccountApi) + return mock + + +@pytest.fixture +def email_api(mock_kc_api): + """Fixture to create an EmailApi instance for testing.""" + return EmailApi(iapi_base_url="http://localhost:8089", kc_api=mock_kc_api) + + +@pytest.fixture +def mock_token(): + """Fixture for a mock Keycloak token.""" + return { + "access_token": "mock_access_token", + "expires_in": 300, # 5 minutes in seconds + "refresh_expires_in": 1800, # 30 minutes in seconds + "refresh_token": "mock_refresh_token", + "token_type": "Bearer", + "id_token": "mock_id_token", + "not_before_policy": "0", + "session_state": "mock_session", + "scope": "openid profile email", + } + + +class TestEmailApiInitialization: + """Tests for EmailApi initialization.""" + + def test_init_with_keycloak_api(self, mock_kc_api): + """Test EmailApi initialization with KeycloakServiceAccountApi.""" + email_api = EmailApi(iapi_base_url="http://localhost:8089", kc_api=mock_kc_api) + + assert email_api.iapi_endpoint == "http://localhost:8089" + assert email_api.kc_api == mock_kc_api + + def test_init_with_different_base_url(self, mock_kc_api): + """Test EmailApi initialization with different base URL.""" + email_api = EmailApi( + iapi_base_url="https://api.example.com", kc_api=mock_kc_api + ) + + assert email_api.iapi_endpoint == "https://api.example.com" + + +class TestSendMessageCounts: + """Tests for send_message_counts method.""" + + @patch("common.email_api.requests.post") + def test_send_message_counts_success( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test successful message counts email send.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + start_date = datetime.datetime(2025, 1, 1) + end_date = datetime.datetime(2025, 1, 2) + + status_code, response = email_api.send_message_counts( + org_name="Test Org", + deployment_title="Test Deployment", + start_date=start_date, + end_date=end_date, + message_type_list=["BSM", "TIM"], + rsu_counts=[{"rsu": "192.168.1.1", "count": 100}], + ) + + assert status_code == 200 + assert response["status"] == "sent" + mock_kc_api.get_kc_token.assert_called_once() + mock_post.assert_called_once() + + call_args = mock_post.call_args + assert call_args[0][0] == "http://localhost:8089/emails/message-counts" + assert call_args[1]["headers"]["Authorization"] == "Bearer mock_access_token" + assert call_args[1]["json"]["org_name"] == "Test Org" + assert call_args[1]["json"]["deployment_title"] == "Test Deployment" + assert call_args[1]["json"]["message_type_list"] == ["BSM", "TIM"] + + @patch("common.email_api.requests.post") + def test_send_message_counts_no_token(self, mock_post, email_api, mock_kc_api): + """Test message counts email when token cannot be obtained.""" + mock_kc_api.get_kc_token.return_value = None + + status_code, response = email_api.send_message_counts( + org_name="Test Org", + deployment_title="Test Deployment", + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + message_type_list=["BSM"], + rsu_counts=[], + ) + + assert status_code == 500 + assert "error" in response + assert response["error"] == "Unable to obtain Keycloak token." + mock_post.assert_not_called() + + @patch("common.email_api.requests.post") + def test_send_message_counts_api_failure( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test message counts email when API returns error.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_response.json.return_value = {"error": "failed to send"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_message_counts( + org_name="Test Org", + deployment_title="Test Deployment", + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + message_type_list=["BSM"], + rsu_counts=[], + ) + + assert status_code == 500 + assert "error" in response + + @patch("common.email_api.requests.post") + def test_send_message_counts_with_empty_counts( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test sending message counts with empty counts list.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_message_counts( + org_name="Test Org", + deployment_title="Test Deployment", + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + message_type_list=["BSM"], + rsu_counts=[], + ) + + assert status_code == 200 + call_args = mock_post.call_args + assert call_args[1]["json"]["rsu_counts"] == [] + + +class TestSendFirmwareUpgradeFailure: + """Tests for send_firmware_upgrade_failure method.""" + + @patch("common.email_api.requests.post") + def test_send_firmware_upgrade_failure_success( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test successful firmware upgrade failure email send.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "email sent"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_firmware_upgrade_failure( + rsu_ip="192.168.1.100", + error_message="SNMP timeout", + failure_type="ConnectionError", + stack_trace="Traceback...", + ) + + assert status_code == 201 + assert response["message"] == "email sent" + mock_kc_api.get_kc_token.assert_called_once() + + call_args = mock_post.call_args + assert ( + call_args[0][0] == "http://localhost:8089/emails/firmware-upgrade-failures" + ) + assert call_args[1]["headers"]["Authorization"] == "Bearer mock_access_token" + assert call_args[1]["json"]["rsu_ip"] == "192.168.1.100" + assert call_args[1]["json"]["message"] == "SNMP timeout" + + @patch("common.email_api.requests.post") + def test_send_firmware_upgrade_failure_no_token( + self, mock_post, email_api, mock_kc_api + ): + """Test firmware upgrade failure email when token cannot be obtained.""" + mock_kc_api.get_kc_token.return_value = None + + status_code, response = email_api.send_firmware_upgrade_failure( + rsu_ip="192.168.1.100", + error_message="Error", + failure_type="Error", + stack_trace="", + ) + + assert status_code == 500 + assert "error" in response + assert response["error"] == "Unable to obtain Keycloak token." + mock_post.assert_not_called() + + @patch("common.email_api.requests.post") + def test_send_firmware_upgrade_failure_api_error( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test firmware upgrade failure email with API error.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Invalid RSU IP" + mock_response.json.return_value = {"error": "validation_error"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_firmware_upgrade_failure( + rsu_ip="invalid_ip", + error_message="Error", + failure_type="Error", + stack_trace="", + ) + + assert status_code == 400 + + @patch("common.email_api.requests.post") + def test_send_firmware_upgrade_failure_with_long_stack_trace( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test firmware upgrade failure email with long stack trace.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + long_stack_trace = "Traceback (most recent call last):\n" * 100 + + status_code, response = email_api.send_firmware_upgrade_failure( + rsu_ip="192.168.1.100", + error_message="Connection timeout", + failure_type="TimeoutError", + stack_trace=long_stack_trace, + ) + + assert status_code == 200 + call_args = mock_post.call_args + assert len(call_args[1]["json"]["stack_trace"]) > 1000 + + +class TestSendApiErrorEmail: + """Tests for send_api_error_email method.""" + + @patch("common.email_api.requests.post") + def test_send_api_error_email_success( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test successful API error email send.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_api_error_email( + error_message="Database connection failed", + stack_trace="Traceback (most recent call last)...", + timestamp="2025-01-05T12:00:00Z", + logs_link="https://logs.example.com", + ) + + assert status_code == 200 + assert response["status"] == "sent" + mock_kc_api.get_kc_token.assert_called_once() + + call_args = mock_post.call_args + assert call_args[0][0] == "http://localhost:8089/emails/api-errors" + assert call_args[1]["headers"]["Authorization"] == "Bearer mock_access_token" + + @patch("common.email_api.requests.post") + def test_send_api_error_email_no_token(self, mock_post, email_api, mock_kc_api): + """Test API error email when token cannot be obtained.""" + mock_kc_api.get_kc_token.return_value = None + + status_code, response = email_api.send_api_error_email( + error_message="Error", + stack_trace="Trace", + timestamp="2025-01-05T12:00:00Z", + logs_link="https://logs.example.com", + ) + + assert status_code == 500 + assert "error" in response + assert response["error"] == "Unable to obtain Keycloak token." + mock_post.assert_not_called() + + @patch("common.email_api.requests.post") + def test_send_api_error_email_with_all_fields( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test API error email with all fields populated.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message_id": "12345"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_api_error_email( + error_message="ValueError: Invalid latitude", + stack_trace="Traceback...\nValueError: Invalid latitude", + timestamp="2025-01-05T14:32:18.456Z", + logs_link="https://cvmanager.example.com/logs?level=error", + ) + + assert status_code == 200 + call_args = mock_post.call_args + json_data = call_args[1]["json"] + assert json_data["error_message"] == "ValueError: Invalid latitude" + assert json_data["stack_trace"] == "Traceback...\nValueError: Invalid latitude" + assert json_data["timestamp"] == "2025-01-05T14:32:18.456Z" + assert ( + json_data["logs_link"] == "https://cvmanager.example.com/logs?level=error" + ) + + @patch("common.email_api.requests.post") + def test_send_api_error_email_api_failure( + self, mock_post, email_api, mock_kc_api, mock_token + ): + """Test API error email when API returns error.""" + mock_kc_api.get_kc_token.return_value = mock_token + + mock_response = Mock() + mock_response.status_code = 503 + mock_response.text = "Service Unavailable" + mock_response.json.return_value = {"error": "service_unavailable"} + mock_post.return_value = mock_response + + status_code, response = email_api.send_api_error_email( + error_message="Error", + stack_trace="Trace", + timestamp="2025-01-05T12:00:00Z", + logs_link="https://logs.example.com", + ) + + assert status_code == 503 + + +class TestEmailApiIntegration: + """Integration tests for EmailApi with KeycloakServiceAccountApi.""" + + @patch("common.email_api.requests.post") + def test_token_refresh_between_calls(self, mock_post, mock_kc_api, mock_token): + """Test that token is refreshed between email calls.""" + email_api = EmailApi(iapi_base_url="http://localhost:8089", kc_api=mock_kc_api) + + # First call uses initial token + mock_kc_api.get_kc_token.return_value = mock_token + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + email_api.send_api_error_email( + error_message="Error 1", + stack_trace="Trace 1", + timestamp="2025-01-05T12:00:00Z", + logs_link="https://logs.example.com", + ) + + # Second call uses refreshed token + refreshed_token = {**mock_token, "access_token": "refreshed_access_token"} + mock_kc_api.get_kc_token.return_value = refreshed_token + + email_api.send_api_error_email( + error_message="Error 2", + stack_trace="Trace 2", + timestamp="2025-01-05T12:05:00Z", + logs_link="https://logs.example.com", + ) + + assert mock_kc_api.get_kc_token.call_count == 2 + + # Verify second call used refreshed token + second_call_args = mock_post.call_args_list[1] + assert ( + second_call_args[1]["headers"]["Authorization"] + == "Bearer refreshed_access_token" + ) + + @patch("common.email_api.requests.post") + def test_multiple_email_types_same_token(self, mock_post, mock_kc_api, mock_token): + """Test that multiple email types can use the same token.""" + email_api = EmailApi(iapi_base_url="http://localhost:8089", kc_api=mock_kc_api) + + mock_kc_api.get_kc_token.return_value = mock_token + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "sent"} + mock_post.return_value = mock_response + + # Send different types of emails + email_api.send_api_error_email( + error_message="Error", + stack_trace="Trace", + timestamp="2025-01-05T12:00:00Z", + logs_link="https://logs.example.com", + ) + + email_api.send_firmware_upgrade_failure( + rsu_ip="192.168.1.100", + error_message="Firmware error", + failure_type="UpgradeError", + stack_trace="Trace", + ) + + email_api.send_message_counts( + org_name="Test Org", + deployment_title="Test Deployment", + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + message_type_list=["BSM"], + rsu_counts=[], + ) + + # Each email send requests a token; all three email types should send successfully. + assert mock_kc_api.get_kc_token.call_count == 3 + assert mock_post.call_count == 3 diff --git a/services/common/tests/test_gcs_utils.py b/services/common/tests/test_gcs_utils.py index d1da789fb..3f4bcfe9d 100644 --- a/services/common/tests/test_gcs_utils.py +++ b/services/common/tests/test_gcs_utils.py @@ -3,9 +3,8 @@ from common import gcs_utils -@patch.dict( - os.environ, {"GCP_PROJECT": "test-project", "BLOB_STORAGE_BUCKET": "test-bucket"} -) +@patch("common.common_environment.GCP_PROJECT", "test-project") +@patch("common.common_environment.BLOB_STORAGE_BUCKET", "test-bucket") @patch("common.gcs_utils.logging") @patch("common.gcs_utils.storage.Client") def test_download_gcp_blob(mock_storage_client, mock_logging): @@ -29,9 +28,8 @@ def test_download_gcp_blob(mock_storage_client, mock_logging): ) -@patch.dict( - os.environ, {"GCP_PROJECT": "test-project", "BLOB_STORAGE_BUCKET": "test-bucket"} -) +@patch("common.common_environment.GCP_PROJECT", "test-project") +@patch("common.common_environment.BLOB_STORAGE_BUCKET", "test-bucket") @patch("common.gcs_utils.storage.Client") def test_list_gcs_blobs(mock_storage_client): # mock @@ -58,9 +56,8 @@ def test_list_gcs_blobs(mock_storage_client): assert result == ["/firmwares/file1.txt", "/firmwares/file3.txt"] -@patch.dict( - os.environ, {"GCP_PROJECT": "test-project", "BLOB_STORAGE_BUCKET": "test-bucket"} -) +@patch("common.common_environment.GCP_PROJECT", "test-project") +@patch("common.common_environment.BLOB_STORAGE_BUCKET", "test-bucket") @patch("common.gcs_utils.storage.Client") def test_list_gcs_blobs_empty(mock_storage_client): # mock diff --git a/services/common/tests/test_keycloak_api.py b/services/common/tests/test_keycloak_api.py new file mode 100644 index 000000000..151caf0e4 --- /dev/null +++ b/services/common/tests/test_keycloak_api.py @@ -0,0 +1,384 @@ +import pytest +import datetime +from unittest.mock import MagicMock, patch +from common.keycloak_api import KeycloakServiceAccountApi + + +@pytest.fixture +def mock_keycloak_openid(): + """ + Fixture to mock the KeycloakOpenID class from python-keycloak library. + + This fixture patches the KeycloakOpenID class to prevent actual network calls + to Keycloak during testing. The mock is yielded and automatically cleaned up + after each test function completes. + + Yields: + MagicMock: A mock object replacing the KeycloakOpenID class. + """ + with patch("common.keycloak_api.KeycloakOpenID") as mock: + yield mock + + +@pytest.fixture +def keycloak_api(mock_keycloak_openid): + """ + Fixture to create a KeycloakServiceAccountApi instance with mocked dependencies. + + This fixture creates a fresh instance of KeycloakServiceAccountApi for each test, + using the mocked KeycloakOpenID class to prevent real authentication attempts. + All configuration values are test doubles. + + Args: + mock_keycloak_openid: The mocked KeycloakOpenID class from the fixture above. + + Returns: + KeycloakServiceAccountApi: A configured instance ready for testing with: + - endpoint: "https://keycloak.example.com" + - realm: "test-realm" + - client_id: "test-client" + - client_secret: "test-secret" + """ + return KeycloakServiceAccountApi( + endpoint="https://keycloak.example.com", + realm="test-realm", + client_id="test-client", + client_secret="test-secret", + ) + + +@pytest.fixture +def sample_token(): + """ + Fixture to provide a sample Keycloak token response dictionary. + + This fixture returns a realistic token response structure that mimics what + the actual Keycloak server would return. It includes all standard OAuth2/OIDC + fields with test values. + + Returns: + dict: A dictionary containing: + - access_token: JWT access token for API authentication + - expires_in: Access token validity period (300 seconds = 5 minutes) + - refresh_expires_in: Refresh token validity period (1800 seconds = 30 minutes) + - refresh_token: Token used to obtain new access tokens + - token_type: OAuth2 token type (Bearer) + - id_token: OIDC identity token + - not_before_policy: Keycloak security policy timestamp + - session_state: Keycloak session identifier + - scope: Requested OAuth2 scopes (openid, email, profile) + """ + return { + "access_token": "sample_access_token", + "expires_in": 300, # 5 minutes + "refresh_expires_in": 1800, # 30 minutes + "refresh_token": "sample_refresh_token", + "token_type": "Bearer", + "id_token": "sample_id_token", + "not_before_policy": "0", + "session_state": "sample_session_state", + "scope": "openid email profile", + } + + +class TestKeycloakServiceAccountApi: + """Test suite for KeycloakServiceAccountApi class.""" + + def test_init(self, keycloak_api, mock_keycloak_openid): + """Test initialization of KeycloakServiceAccountApi.""" + assert keycloak_api.endpoint == "https://keycloak.example.com" + assert keycloak_api.realm == "test-realm" + assert keycloak_api.client_id == "test-client" + assert keycloak_api.client_secret == "test-secret" + assert keycloak_api.token is None + assert keycloak_api.token_expiration_date is None + assert keycloak_api.refresh_token_expiration_date is None + + # Verify KeycloakOpenID was initialized correctly + mock_keycloak_openid.assert_called_once_with( + server_url="https://keycloak.example.com", + realm_name="test-realm", + client_id="test-client", + client_secret_key="test-secret", + ) + + def test_gen_keycloak_token(self, keycloak_api, sample_token): + """Test generating a new Keycloak token.""" + keycloak_api.keycloak_openid.token = MagicMock(return_value=sample_token) + + result = keycloak_api._gen_keycloak_token() + + assert result == sample_token + keycloak_api.keycloak_openid.token.assert_called_once_with( + grant_type="client_credentials", + client_id="test-client", + client_secret="test-secret", + scope="openid", + ) + + def test_refresh_keycloak_token_success(self, keycloak_api, sample_token): + """Test successfully refreshing a Keycloak token.""" + refreshed_token = {**sample_token, "access_token": "new_access_token"} + keycloak_api.keycloak_openid.refresh_token = MagicMock( + return_value=refreshed_token + ) + + result = keycloak_api._refresh_keycloak_token("old_refresh_token") + + assert result == refreshed_token + keycloak_api.keycloak_openid.refresh_token.assert_called_once_with( + "old_refresh_token" + ) + + def test_refresh_keycloak_token_failure(self, keycloak_api): + """Test refresh token failure handling.""" + keycloak_api.keycloak_openid.refresh_token = MagicMock( + side_effect=Exception("Invalid refresh token") + ) + + result = keycloak_api._refresh_keycloak_token("invalid_refresh_token") + + assert result is None + + def test_is_current_token_valid_no_token(self, keycloak_api): + """Test token validation when no token exists.""" + assert keycloak_api._is_current_token_valid() is False + + def test_is_current_token_valid_expired(self, keycloak_api, sample_token): + """Test token validation when token is expired.""" + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + + assert keycloak_api._is_current_token_valid() is False + + def test_is_current_token_valid_not_expired(self, keycloak_api, sample_token): + """Test token validation when token is still valid.""" + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=5) + ) + + assert keycloak_api._is_current_token_valid() is True + + def test_is_refresh_token_valid_no_token(self, keycloak_api): + """Test refresh token validation when no token exists.""" + assert keycloak_api._is_refresh_token_valid() is False + + def test_is_refresh_token_valid_expired(self, keycloak_api, sample_token): + """Test refresh token validation when refresh token is expired.""" + keycloak_api.token = sample_token + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + + assert keycloak_api._is_refresh_token_valid() is False + + def test_is_refresh_token_valid_not_expired(self, keycloak_api, sample_token): + """Test refresh token validation when refresh token is still valid.""" + keycloak_api.token = sample_token + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=30) + ) + + assert keycloak_api._is_refresh_token_valid() is True + + def test_update_token(self, keycloak_api, sample_token): + """Test _update_token method correctly sets token and expiration dates.""" + current_time = datetime.datetime.now() + + with patch("common.keycloak_api.datetime") as mock_datetime: + mock_datetime.datetime.now.return_value = current_time + mock_datetime.timedelta = datetime.timedelta + + keycloak_api._update_token(sample_token) + + assert keycloak_api.token == sample_token + assert keycloak_api.token_expiration_date == current_time + datetime.timedelta( + seconds=300 + ) + assert ( + keycloak_api.refresh_token_expiration_date + == current_time + datetime.timedelta(seconds=1800) + ) + + def test_get_kc_token_no_existing_token(self, keycloak_api, sample_token): + """Test get_kc_token when no token exists.""" + keycloak_api.keycloak_openid.token = MagicMock(return_value=sample_token) + + result = keycloak_api.get_kc_token() + + assert result == sample_token + assert keycloak_api.token == sample_token + + def test_get_kc_token_valid_token_exists(self, keycloak_api, sample_token): + """Test get_kc_token when valid token already exists.""" + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=5) + ) + + result = keycloak_api.get_kc_token() + + assert result == sample_token + # Should not call token generation + keycloak_api.keycloak_openid._token.assert_not_called() + + def test_get_kc_token_expired_but_refresh_valid(self, keycloak_api, sample_token): + """Test get_kc_token when access token expired but refresh token is valid.""" + refreshed_token = {**sample_token, "access_token": "new_access_token"} + + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=20) + ) + keycloak_api.keycloak_openid.refresh_token = MagicMock( + return_value=refreshed_token + ) + + result = keycloak_api.get_kc_token() + + assert result == refreshed_token + assert keycloak_api.token == refreshed_token + keycloak_api.keycloak_openid.refresh_token.assert_called_once_with( + sample_token["refresh_token"] + ) + + def test_get_kc_token_refresh_fails_generates_new(self, keycloak_api, sample_token): + """Test get_kc_token falls back to generating new token when refresh fails.""" + new_token = {**sample_token, "access_token": "brand_new_access_token"} + + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=20) + ) + keycloak_api.keycloak_openid.refresh_token = MagicMock(return_value=None) + keycloak_api.keycloak_openid.token = MagicMock(return_value=new_token) + + result = keycloak_api.get_kc_token() + + assert result == new_token + keycloak_api.keycloak_openid.token.assert_called_once() + + def test_get_kc_token_both_tokens_expired(self, keycloak_api, sample_token): + """Test get_kc_token when both access and refresh tokens are expired.""" + new_token = {**sample_token, "access_token": "brand_new_access_token"} + + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.keycloak_openid.token = MagicMock(return_value=new_token) + + result = keycloak_api.get_kc_token() + + assert result == new_token + # Should not attempt refresh + keycloak_api.keycloak_openid.refresh_token.assert_not_called() + + def test_get_kc_token_generation_fails(self, keycloak_api): + """Test get_kc_token when token generation fails.""" + keycloak_api.keycloak_openid.token = MagicMock(return_value=None) + + result = keycloak_api.get_kc_token() + + assert result is None + + def test_get_kc_token_refresh_exception_then_generate( + self, keycloak_api, sample_token + ): + """Test get_kc_token handles refresh exception and generates new token.""" + new_token = {**sample_token, "access_token": "brand_new_access_token"} + + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=20) + ) + keycloak_api.keycloak_openid.refresh_token = MagicMock( + side_effect=Exception("Network error") + ) + keycloak_api.keycloak_openid.token = MagicMock(return_value=new_token) + + result = keycloak_api.get_kc_token() + + assert result == new_token + + def test_token_expiration_calculation_accuracy(self, keycloak_api, sample_token): + """Test that token expiration dates are calculated accurately.""" + freeze_time = datetime.datetime(2024, 1, 15, 12, 0, 0) + + with patch("common.keycloak_api.datetime") as mock_datetime: + mock_datetime.datetime.now.return_value = freeze_time + mock_datetime.timedelta = datetime.timedelta + + keycloak_api._update_token(sample_token) + + expected_token_expiry = freeze_time + datetime.timedelta(seconds=300) + expected_refresh_expiry = freeze_time + datetime.timedelta(seconds=1800) + + assert keycloak_api.token_expiration_date == expected_token_expiry + assert keycloak_api.refresh_token_expiration_date == expected_refresh_expiry + + def test_multiple_token_refreshes(self, keycloak_api, sample_token): + """Test multiple sequential token refreshes.""" + tokens = [{**sample_token, "access_token": f"token_{i}"} for i in range(3)] + + keycloak_api.keycloak_openid.refresh_token = MagicMock(side_effect=tokens) + + for i, expected_token in enumerate(tokens): + keycloak_api.token = sample_token + keycloak_api.token_expiration_date = ( + datetime.datetime.now() - datetime.timedelta(minutes=1) + ) + keycloak_api.refresh_token_expiration_date = ( + datetime.datetime.now() + datetime.timedelta(minutes=20) + ) + + result = keycloak_api.get_kc_token() + assert result["access_token"] == f"token_{i}" + + @pytest.mark.parametrize( + "expires_in,refresh_expires_in", + [ + (60, 300), # 1 min, 5 min + (300, 1800), # 5 min, 30 min + (3600, 86400), # 1 hour, 24 hours + ], + ) + def test_various_expiration_times( + self, keycloak_api, sample_token, expires_in, refresh_expires_in + ): + """Test token handling with various expiration times.""" + token = { + **sample_token, + "expires_in": expires_in, + "refresh_expires_in": refresh_expires_in, + } + + current_time = datetime.datetime.now() + with patch("common.keycloak_api.datetime") as mock_datetime: + mock_datetime.datetime.now.return_value = current_time + mock_datetime.timedelta = datetime.timedelta + + keycloak_api._update_token(token) + + assert keycloak_api.token_expiration_date == current_time + datetime.timedelta( + seconds=expires_in + ) + assert ( + keycloak_api.refresh_token_expiration_date + == current_time + datetime.timedelta(seconds=refresh_expires_in) + ) diff --git a/services/common/tests/test_pgquery.py b/services/common/tests/test_pgquery.py index ab3c40f05..db73cf591 100644 --- a/services/common/tests/test_pgquery.py +++ b/services/common/tests/test_pgquery.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch, Mock from common import pgquery import sqlalchemy -import os # test that init_tcp_connection_engine is calling sqlalchemy.create_engine with expected arguments @@ -93,6 +92,10 @@ def test_init_socket_connection_engine(): "common.pgquery.db_config", new={"pool_size": 5, "max_overflow": 2, "pool_timeout": 30, "pool_recycle": 1800}, ) +@patch("common.common_environment.PG_DB_USER", "user") +@patch("common.common_environment.PG_DB_PASS", "pass") +@patch("common.common_environment.PG_DB_NAME", "my_database") +@patch("common.common_environment.PG_DB_HOST", "my_hostname:3000") def test_init_connection_engine_target_tcp(): # mock return values for function dependencies pgquery.init_tcp_connection_engine = MagicMock(return_value="my_engine1") @@ -103,12 +106,6 @@ def test_init_connection_engine_target_tcp(): db_name = "my_database" db_hostname = "my_hostname:3000" - # set environment variables - os.environ["PG_DB_USER"] = db_user - os.environ["PG_DB_PASS"] = db_pass - os.environ["PG_DB_NAME"] = db_name - os.environ["PG_DB_HOST"] = db_hostname - host_args = db_hostname.split(":") db_hostname, db_port = host_args[0], int(host_args[1]) @@ -133,6 +130,13 @@ def test_init_connection_engine_target_tcp(): new={"pool_size": 5, "max_overflow": 2, "pool_timeout": 30, "pool_recycle": 1800}, ) @patch("common.pgquery.db", new=None) +@patch("common.common_environment.PG_DB_USER", "user") +@patch("common.common_environment.PG_DB_PASS", "pass") +@patch("common.common_environment.PG_DB_NAME", "my_database") +@patch( + "common.common_environment.INSTANCE_CONNECTION_NAME", + "myproject:us-central1:myinstance", +) def test_init_connection_engine_target_socket(): # mock return values for function dependencies pgquery.init_tcp_connection_engine = MagicMock(return_value="my_engine1") @@ -142,14 +146,8 @@ def test_init_connection_engine_target_socket(): db_pass = "pass" db_name = "my_database" - # set environment variables - os.environ["PG_DB_USER"] = db_user - os.environ["PG_DB_PASS"] = db_pass - os.environ["PG_DB_NAME"] = db_name - os.environ["INSTANCE_CONNECTION_NAME"] = "my_project:us-central1:my_instance" - unix_query = { - "unix_sock": f"/cloudsql/{os.environ['INSTANCE_CONNECTION_NAME']}/.s.PGSQL.5432" + "unix_sock": "/cloudsql/myproject:us-central1:myinstance/.s.PGSQL.5432" } # call function diff --git a/services/common/util.py b/services/common/util.py index 92405210f..915becf1f 100644 --- a/services/common/util.py +++ b/services/common/util.py @@ -1,7 +1,8 @@ from dateutil.parser import parse import pytz -import os import logging +import datetime +from common import common_environment # expects datetime string @@ -24,7 +25,7 @@ def format_date_denver(d): if not d: return None tmp = parse(d) - denver_tz = tmp.astimezone(pytz.timezone(os.getenv("TIMEZONE", "America/Denver"))) + denver_tz = tmp.astimezone(pytz.timezone(common_environment.TIMEZONE)) return denver_tz.strftime("%m/%d/%Y %I:%M:%S %p") @@ -33,19 +34,19 @@ def format_date_denver_iso(d): if not d: return None tmp = parse(d) - denver_tz = tmp.astimezone(pytz.timezone(os.getenv("TIMEZONE", "America/Denver"))) + denver_tz = tmp.astimezone(pytz.timezone(common_environment.TIMEZONE)) return denver_tz.isoformat() # expects datetime, utilizes environment variable to custom timezone -def utc2tz(d): +def utc2tz(d: datetime.datetime): if not d: return None - tz_d = d.astimezone(pytz.timezone(os.getenv("TIMEZONE", "America/Denver"))) + tz_d = d.astimezone(pytz.timezone(common_environment.TIMEZONE)) return tz_d -def validate_file_type(file_name, extension=".tar"): +def validate_file_type(file_name: str, extension=".tar"): """Validate the file type of the file to be downloaded. Args: diff --git a/services/intersection-api/README.md b/services/intersection-api/README.md index e5664f9d2..31983d453 100644 --- a/services/intersection-api/README.md +++ b/services/intersection-api/README.md @@ -15,6 +15,48 @@ All of the data flowing into and out of the intersection api is shown in the fol To view the endpoint configuration of the api, open the following HTML page in a browser: [docs/swagger-docs/docs.html](./docs/swagger-docs/docs.html) +## Emails + +The intersection API exposes a set of `/emails/*` endpoints for triggering outbound notification emails. The following email types are supported: + +| Endpoint | Role Required | Description | Example | +|---|---|---|---| +| `POST /emails/support-requests` | Any authenticated user | User-submitted support request forwarded to the support team | [support_request_email_multiline_snapshot.html](api/src/test/resources/snapshots/emails/support_request_email_multiline_snapshot.html) | +| `POST /emails/intersection-notifications` | `USER` or super user | Summary of active intersection notifications for configured recipients | [intersection_notification_summary_email_snapshot.html](api/src/test/resources/snapshots/emails/intersection_notification_summary_email_snapshot.html) | +| `POST /emails/rsu-errors` | `USER` or super user | Summary of RSU errors detected by the conflict monitor | [rsu_error_summary_email_snapshot.html](api/src/test/resources/snapshots/emails/rsu_error_summary_email_snapshot.html) | +| `POST /emails/api-errors` | `ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS` or super user | Critical API error notification with stack trace | [api_error_email_snapshot.html](api/src/test/resources/snapshots/emails/api_error_email_snapshot.html) | +| `POST /emails/message-counts` | `ROLE_SEND_MESSAGE_COUNTS_EMAILS` or super user | Per-intersection message count summary | [message_count_snapshot.html](api/src/test/resources/snapshots/emails/message_count_snapshot.html) | +| `POST /emails/firmware-upgrade-failures` | `ROLE_SEND_FIRMWARE_UPGRADE_EMAILS` or super user | Firmware upgrade failure notification for a specific RSU | [firmware_upgrade_failure_snapshot.html](api/src/test/resources/snapshots/emails/firmware_upgrade_failure_snapshot.html) | + +All email endpoints require `enable.api=true` and `enable.email=true`. + +### Rate Limiting + +The intersection API uses [Bucket4j](https://github.com/bucket4j/bucket4j) to enforce token-bucket rate limits on all `/emails/*` endpoints. Two independent limits are applied per request — both must have capacity remaining for the request to proceed, otherwise HTTP 429 is returned. + +#### Per-User Limit + +Each caller gets their own bucket, keyed by their JWT subject (`sub` claim). If no valid JWT is present the client's remote IP address is used as the fallback key. + +| Environment Variable | Default | Description | +|---|---|---| +| `EMAIL_RATE_LIMIT_PER_USER` | `12` | Maximum email requests per user per hour | + +#### Global Limit + +A single shared bucket caps the total number of emails the instance will send per hour, regardless of how many individual users are making requests. + +| Environment Variable | Default | Description | +|---|---|---| +| `EMAIL_RATE_LIMIT_PER_INSTANCE` | `120` | Maximum email requests across all users per hour | + +#### How It Works + +- Rate limiting is backed by a [Caffeine](https://github.com/ben-manes/caffeine) in-memory cache (`email-rate-limit`) with a 1-hour expiry window. +- The JWT subject is extracted by the `JwtSubjectExtractor` Spring bean. When a `JwtDecoder` bean is available (always in production), it decodes the token using the configured Keycloak JWKS endpoint to obtain the `sub` claim. If decoding fails the IP-based fallback is used. +- Both limits use `strategy: all`, meaning a request is blocked (HTTP 429) if **either** bucket is exhausted. +- The rate limits can be disabled entirely by setting `bucket4j.enabled=false`. + ## Framework This is a Java SpringBoot application, utilizing REST and STOMP protocols. This application is fully dockerized and is designed to run alongside an instance of the [jpo-ode](https://github.com/usdot-jpo-ode/jpo-ode), [jpo-geojsonconverter](https://github.com/usdot-jpo-ode/jpo-geojsonconverter), [jpo-conflictmonitor](https://github.com/usdot-jpo-ode/jpo-conflictmonitor). This project imports pre-built images for these services from (DockerHub)[https://hub.docker.com/u/usdotjpoode]. If you would like to build them locally, information is available in their respective repositories. diff --git a/services/intersection-api/api/pom.xml b/services/intersection-api/api/pom.xml index 523a89964..d16947161 100644 --- a/services/intersection-api/api/pom.xml +++ b/services/intersection-api/api/pom.xml @@ -27,6 +27,8 @@ usdot-jpo-ode 19-SNAPSHOT + 1.5.5.Final + 0.2.0 @@ -56,10 +58,6 @@ kafka-clients 3.2.3 - - org.springframework.boot - spring-boot-starter-thymeleaf - com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -100,6 +98,12 @@ spring-kafka-test test + + io.github.java-diff-utils + java-diff-utils + 4.12 + test + usdot.jpo.ode jpo-ode-core @@ -199,6 +203,11 @@ 21.1.2 + + com.nimbusds + nimbus-jose-jwt + 9.38 + org.springframework.boot spring-boot-starter-mail @@ -207,6 +216,10 @@ org.springframework.boot spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-webflux + org.springframework.security spring-security-test @@ -238,17 +251,54 @@ sendgrid-java 4.10.1 + + javax.mail + javax.mail-api + 1.6.2 + + + com.sun.mail + jakarta.mail + 2.0.1 + + + jakarta.activation + jakarta.activation-api + 2.1.0 + + + com.sun.activation + javax.activation + 1.2.0 + com.postmarkapp postmark 1.11.1 + + org.springframework.boot + spring-boot-starter-thymeleaf + org.postgresql postgresql runtime + + org.hibernate.orm + hibernate-spatial + + + org.hibernate.orm + hibernate-community-dialects + + + org.locationtech.jts + jts-core + 1.20.0 + org.springframework.boot spring-boot-starter-jdbc @@ -272,18 +322,31 @@ gt-referencing 25.2 - - io.zonky.test - embedded-database-spring-test - 2.5.0 - test - - - ch.qos.logback - logback-classic - - - + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + mongodb + test + + + org.testcontainers + junit-jupiter + test + usdot.jpo.ode j2735-2024-ffm-lib @@ -294,8 +357,47 @@ json-unit-assertj 5.0.0 test + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + com.github.javafaker + javafaker + 1.0.2 + test + + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter + 0.12.7 + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + com.github.ben-manes.caffeine + jcache + + + + org.testcontainers + testcontainers-bom + 1.21.4 + pom + import + + + geotools @@ -348,6 +450,35 @@ maven-surefire-plugin 3.2.5 + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + + + + + preview_text +

+ + + + + + + + + + + + + + + + +   + + + + + diff --git a/services/intersection-api/api/src/main/resources/templates/emails/email_template_blank.png b/services/intersection-api/api/src/main/resources/templates/emails/email_template_blank.png new file mode 100644 index 000000000..eec3e38a7 Binary files /dev/null and b/services/intersection-api/api/src/main/resources/templates/emails/email_template_blank.png differ diff --git a/services/intersection-api/api/src/main/resources/templates/emails/email_template_firmware_upgrade_failure.html b/services/intersection-api/api/src/main/resources/templates/emails/email_template_firmware_upgrade_failure.html new file mode 100644 index 000000000..dd090ff72 --- /dev/null +++ b/services/intersection-api/api/src/main/resources/templates/emails/email_template_firmware_upgrade_failure.html @@ -0,0 +1,344 @@ + + + + + + head_title + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/main/resources/templates/emails/email_template_message_counts.html b/services/intersection-api/api/src/main/resources/templates/emails/email_template_message_counts.html new file mode 100644 index 000000000..2a317e8e3 --- /dev/null +++ b/services/intersection-api/api/src/main/resources/templates/emails/email_template_message_counts.html @@ -0,0 +1,398 @@ + + + + + + head_title + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/EmailSettingsTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/EmailSettingsTest.java deleted file mode 100644 index e5e1d2a97..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/EmailSettingsTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package us.dot.its.jpo.ode.api; - -import static org.junit.Assert.assertEquals; - -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.ode.api.models.EmailSettings; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class EmailSettingsTest { - - @Test - public void testEmailAttributesEncodeDecode() { - - EmailSettings settings = new EmailSettings(); - - settings.setReceiveCeaseBroadcastRecommendations(false); - settings.setReceiveNewUserRequests(false); - settings.setReceiveCriticalErrorMessages(false); - settings.setReceiveAnnouncements(false); - - Map> attributes = settings.toAttributes(); - - EmailSettings decodedSettings = EmailSettings.fromAttributes(attributes); - - assertEquals(decodedSettings.getNotificationFrequency(), settings.getNotificationFrequency()); - assertEquals(decodedSettings.isReceiveAnnouncements(), settings.isReceiveAnnouncements()); - assertEquals(decodedSettings.isReceiveCeaseBroadcastRecommendations(), - settings.isReceiveCeaseBroadcastRecommendations()); - assertEquals(decodedSettings.isReceiveCriticalErrorMessages(), settings.isReceiveCriticalErrorMessages()); - assertEquals(decodedSettings.isReceiveNewUserRequests(), settings.isReceiveNewUserRequests()); - - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/JwtSubjectExtractorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/JwtSubjectExtractorTest.java new file mode 100644 index 000000000..176f70007 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/JwtSubjectExtractorTest.java @@ -0,0 +1,76 @@ +package us.dot.its.jpo.ode.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +@ExtendWith(MockitoExtension.class) +class JwtSubjectExtractorTest { + + @Test + void extractSubject_nullHeader_returnsNull() { + JwtSubjectExtractor extractor = new JwtSubjectExtractor(null); + assertNull(extractor.extractSubject(null)); + } + + @Test + void extractSubject_blankHeader_returnsNull() { + JwtSubjectExtractor extractor = new JwtSubjectExtractor(null); + assertNull(extractor.extractSubject(" ")); + } + + @Test + void extractSubject_missingTokenPart_returnsNull() { + JwtSubjectExtractor extractor = new JwtSubjectExtractor(null); + assertNull(extractor.extractSubject("Bearer")); + } + + @Test + void extractSubject_withJwtDecoder_decodedSubjectReturned() { + JwtDecoder decoder = mock(JwtDecoder.class); + when(decoder.decode("tokenA")).thenReturn(Jwt.withTokenValue("tokenA") + .headers(h -> h.put("alg", "none")) + .claims(c -> c.put("sub", "decoded-sub")) + .build()); + + JwtSubjectExtractor extractor = new JwtSubjectExtractor(decoder); + assertEquals("decoded-sub", extractor.extractSubject("Bearer tokenA")); + } + + @Test + void extractSubject_withJwtDecoderThrowing_returnsNull() { + JwtDecoder decoder = mock(JwtDecoder.class); + when(decoder.decode("tokenX")).thenThrow(new RuntimeException("bad token")); + + JwtSubjectExtractor extractor = new JwtSubjectExtractor(decoder); + assertNull(extractor.extractSubject("Bearer tokenX")); + } + + @Test + void extractSubject_fallbackBase64Payload_returnsSub() { + // Build a JWT-like token with a base64url payload containing {"sub":"payload-sub"} + String headerJson = "{\"alg\":\"none\"}"; + String payloadJson = "{\"sub\":\"payload-sub\"}"; + String header = Base64.getUrlEncoder().withoutPadding().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + String token = header + "." + payload + ".sig"; + + JwtSubjectExtractor extractor = new JwtSubjectExtractor(null); + assertEquals("payload-sub", extractor.extractSubject("Bearer " + token)); + } + + @Test + void extractSubject_fallbackBase64_invalidPayload_returnsNull() { + JwtSubjectExtractor extractor = new JwtSubjectExtractor(null); + // Only two parts but the payload is not valid base64 + assertNull(extractor.extractSubject("Bearer header.!!!.sig")); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/SnapshotTestUtils.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/SnapshotTestUtils.java new file mode 100644 index 000000000..5716bdb60 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/SnapshotTestUtils.java @@ -0,0 +1,101 @@ +package us.dot.its.jpo.ode.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import com.github.difflib.DiffUtils; +import com.github.difflib.UnifiedDiffUtils; +import com.github.difflib.patch.Patch; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SnapshotTestUtils { + private static final String SNAPSHOT_DIR = "src/test/resources/snapshots"; + + /** + * Helper method to compare generated content with saved snapshot. + * If snapshot doesn't exist, it will be created. + * If UPDATE_SNAPSHOTS=true environment variable is set, snapshots will be + * updated. + */ + public static void assertMatchesSnapshot(String actualContent, String snapshotPath) throws IOException { + Path path = Paths.get(SNAPSHOT_DIR + "/" + snapshotPath); + boolean updateSnapshots = Boolean.parseBoolean(System.getenv().getOrDefault("UPDATE_SNAPSHOTS", "false")); + + // Create directory if it doesn't exist + Files.createDirectories(path.getParent()); + boolean existedBefore = Files.exists(path); + + if (!existedBefore || updateSnapshots) { + // Create or update snapshot + Files.writeString(path, actualContent); + log.info("Snapshot {}: {}", (existedBefore ? "updated" : "created"), snapshotPath); + + if (!updateSnapshots && !existedBefore) { + fail("Snapshot file created. Please review and commit: " + snapshotPath); + } + } else { + // Compare with existing snapshot + String expectedContent = Files.readString(path); + String normalizedExpected = normalizeLineEndings(expectedContent); + String normalizedActual = normalizeLineEndings(actualContent); + + if (!normalizedExpected.equals(normalizedActual)) { + // Print detailed diff + String diff = generateDiff(normalizedExpected, normalizedActual); + fail(String.format( + "Generated email content does not match snapshot: %s\n\n" + + "To update snapshots, run tests with UPDATE_SNAPSHOTS=true\n\n" + + "DIFF:\n%s\n\n" + + "EXPECTED:\n%s\n\n" + + "ACTUAL:\n%s", + snapshotPath, + diff, + normalizedExpected, + normalizedActual)); + } + } + } + + /** + * Normalize line endings for cross-platform compatibility + */ + private static String normalizeLineEndings(String content) { + return content.replaceAll("\r\n", "\n").trim(); + } + + /** + * Generate a unified diff between expected and actual content using + * java-diff-utils + */ + private static String generateDiff(String expected, String actual) { + List expectedLines = List.of(expected.split("\n")); + List actualLines = List.of(actual.split("\n")); + + Patch patch = DiffUtils.diff(expectedLines, actualLines); + List unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff( + "expected", + "actual", + expectedLines, + patch, + 3 // context lines + ); + + StringBuilder diff = new StringBuilder(); + diff.append("===================================\n"); + diff.append("Snapshot Unified Diff\n"); + diff.append("===================================\n"); + + for (String line : unifiedDiff) { + diff.append(line).append("\n"); + } + + return diff.toString(); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/StringToZonedDateTimeConverterTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/StringToZonedDateTimeConverterTest.java index fc47f30cf..5939415c3 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/StringToZonedDateTimeConverterTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/StringToZonedDateTimeConverterTest.java @@ -5,20 +5,11 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.ode.api.converters.StringToZonedDateTimeConverter; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase public class StringToZonedDateTimeConverterTest { + String[] inputTimeStrings = { "2025-01-21T21:14:45.123Z", "2025-01-21T21:14:45.12Z", diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/TestcontainersConfiguration.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/TestcontainersConfiguration.java new file mode 100644 index 000000000..dcbbf163a --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/TestcontainersConfiguration.java @@ -0,0 +1,34 @@ +package us.dot.its.jpo.ode.api; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.MongoDBContainer; + +/** + * Shared Testcontainers configuration for integration tests. + * + *

Declares a single {@link MongoDBContainer} bean annotated with {@link ServiceConnection}, + * which Spring Boot uses to automatically configure {@code spring.data.mongodb.uri}. Import this + * class in any {@code @SpringBootTest} that needs a real MongoDB connection: + * + *

{@code
+ * @SpringBootTest
+ * @ActiveProfiles("integration-test")
+ * @Import(TestcontainersConfiguration.class)
+ * class MyIntegrationTest { ... }
+ * }
+ * + *

Spring's test context cache ensures that test classes with identical configurations share a + * single {@link org.springframework.context.ApplicationContext} — and therefore a single container + * instance — for the duration of the test suite. + */ +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + MongoDBContainer mongoDbContainer() { + return new MongoDBContainer("mongo:7"); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/ConnectionOfTravelAssessmentRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/ConnectionOfTravelAssessmentRepositoryImplTest.java index 5ce65777c..3f6c5e1bc 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/ConnectionOfTravelAssessmentRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/ConnectionOfTravelAssessmentRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -39,20 +38,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.ConnectionOfTravelAssessment; import us.dot.its.jpo.ode.api.accessors.assessments.connection_of_travel_assessment.ConnectionOfTravelAssessmentRepositoryImpl; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ConnectionOfTravelAssessmentRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/LaneDirectionOfTravelAssessmentRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/LaneDirectionOfTravelAssessmentRepositoryImplTest.java index 8b69f37f6..a30b9117e 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/LaneDirectionOfTravelAssessmentRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/LaneDirectionOfTravelAssessmentRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -39,20 +38,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.LaneDirectionOfTravelAssessment; import us.dot.its.jpo.ode.api.accessors.assessments.lane_direction_of_travel_assessment.LaneDirectionOfTravelAssessmentRepositoryImpl; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class LaneDirectionOfTravelAssessmentRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLinePassageAssessmentRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLinePassageAssessmentRepositoryImplTest.java index 39bcec684..9fa3188dd 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLinePassageAssessmentRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLinePassageAssessmentRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -39,20 +38,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.StopLinePassageAssessment; import us.dot.its.jpo.ode.api.accessors.assessments.stop_line_passage_assessment.StopLinePassageAssessmentRepositoryImpl; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class StopLinePassageAssessmentRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLineStopAssessmentRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLineStopAssessmentRepositoryImplTest.java index 7dddfb470..d6f78503d 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLineStopAssessmentRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/assessments/StopLineStopAssessmentRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,20 +23,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.StopLineStopAssessment; import us.dot.its.jpo.ode.api.accessors.assessments.stop_line_stop_assessment.StopLineStopAssessmentRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class StopLineStopAssessmentRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -53,7 +46,6 @@ public class StopLineStopAssessmentRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new StopLineStopAssessmentRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/bsm/OdeBsmJsonRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/bsm/OdeBsmJsonRepositoryImplTest.java index eaee6db94..c6dee21a9 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/bsm/OdeBsmJsonRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/bsm/OdeBsmJsonRepositoryImplTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -38,6 +37,8 @@ import static org.mockito.Mockito.when; import us.dot.its.jpo.ode.api.accessors.bsm.OdeBsmJsonRepositoryImpl; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; import us.dot.its.jpo.ode.model.OdeMessageFrameData; @@ -45,16 +46,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class OdeBsmJsonRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/BsmEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/BsmEventRepositoryImplTest.java index a16550e3a..6b80e1f26 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/BsmEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/BsmEventRepositoryImplTest.java @@ -3,12 +3,12 @@ import java.util.Date; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; @@ -34,18 +34,10 @@ import us.dot.its.jpo.ode.api.accessors.events.bsm_event.BsmEventRepositoryImpl; import us.dot.its.jpo.ode.api.models.IDCount; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class BsmEventRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -60,7 +52,6 @@ public class BsmEventRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new BsmEventRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/ConnectionOfTravelEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/ConnectionOfTravelEventRepositoryImplTest.java index b98d044cd..41d89314b 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/ConnectionOfTravelEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/ConnectionOfTravelEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -40,6 +39,8 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.events.ConnectionOfTravelEvent; import us.dot.its.jpo.ode.api.accessors.events.connection_of_travel_event.ConnectionOfTravelEventRepositoryImpl; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; import us.dot.its.jpo.ode.api.models.IDCount; @@ -47,16 +48,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ConnectionOfTravelEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/IntersectionReferenceAlignmentEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/IntersectionReferenceAlignmentEventRepositoryImplTest.java index df2cfc67e..bed9280ef 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/IntersectionReferenceAlignmentEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/IntersectionReferenceAlignmentEventRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -34,18 +34,10 @@ import us.dot.its.jpo.ode.api.accessors.events.intersection_reference_alignment_event.IntersectionReferenceAlignmentEventRepositoryImpl; import us.dot.its.jpo.ode.api.models.IDCount; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class IntersectionReferenceAlignmentEventRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -62,7 +54,6 @@ public class IntersectionReferenceAlignmentEventRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new IntersectionReferenceAlignmentEventRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/LaneDirectionOfTravelEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/LaneDirectionOfTravelEventRepositoryImplTest.java index 733c26643..1fa8e5725 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/LaneDirectionOfTravelEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/LaneDirectionOfTravelEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -47,16 +46,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class LaneDirectionOfTravelEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapBroadcastRateEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapBroadcastRateEventRepositoryImplTest.java index 563b72aa5..ad71bb679 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapBroadcastRateEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapBroadcastRateEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -45,18 +44,17 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.events.broadcast_rate.MapBroadcastRateEvent; import us.dot.its.jpo.ode.api.accessors.events.map_broadcast_rate_event.MapBroadcastRateEventRepositoryImpl; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class MapBroadcastRateEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapMinimumDataEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapMinimumDataEventRepositoryImplTest.java index b62b69bb2..8e16eca8e 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapMinimumDataEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/MapMinimumDataEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -45,18 +44,17 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.events.minimum_data.MapMinimumDataEvent; import us.dot.its.jpo.ode.api.accessors.events.map_minimum_data_event.MapMinimumDataEventRepositoryImpl; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class MapMinimumDataEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalGroupAlignmentEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalGroupAlignmentEventRepositoryImplTest.java index 46097713c..4c23e9997 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalGroupAlignmentEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalGroupAlignmentEventRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -33,19 +33,12 @@ import us.dot.its.jpo.ode.api.accessors.events.signal_group_alignment_event.SignalGroupAlignmentEventRepositoryImpl; import us.dot.its.jpo.ode.api.models.IDCount; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.events.SignalGroupAlignmentEvent; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class SignalGroupAlignmentEventRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -62,7 +55,6 @@ public class SignalGroupAlignmentEventRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new SignalGroupAlignmentEventRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalStateConflictEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalStateConflictEventRepositoryImplTest.java index 30e2a37fd..8ee6a8797 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalStateConflictEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SignalStateConflictEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -46,17 +45,16 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.events.SignalStateConflictEvent; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class SignalStateConflictEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatBroadcastRateRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatBroadcastRateRepositoryImplTest.java index 1e2ce6ff3..96867d349 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatBroadcastRateRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatBroadcastRateRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -47,16 +46,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class SpatBroadcastRateRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatMinimumDataEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatMinimumDataEventRepositoryImplTest.java index 187c848e1..632bceb63 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatMinimumDataEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/SpatMinimumDataEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -47,16 +46,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class SpatMinimumDataEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLinePassageEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLinePassageEventRepositoryImplTest.java index a3f9e39e0..a13368624 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLinePassageEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLinePassageEventRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -47,16 +46,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class StopLinePassageEventRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLineStopEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLineStopEventRepositoryImplTest.java index 106e4bd48..899c01a49 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLineStopEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/StopLineStopEventRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -33,19 +33,12 @@ import us.dot.its.jpo.ode.api.accessors.events.stop_line_stop_event.StopLineStopEventRepositoryImpl; import us.dot.its.jpo.ode.api.models.IDCount; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.events.StopLineStopEvent; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class StopLineStopEventRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -62,7 +55,6 @@ public class StopLineStopEventRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new StopLineStopEventRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/TimeChangeDetailsEventRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/TimeChangeDetailsEventRepositoryImplTest.java index c1866b24d..18508c42e 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/TimeChangeDetailsEventRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/events/TimeChangeDetailsEventRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -33,19 +33,12 @@ import us.dot.its.jpo.ode.api.accessors.events.time_change_details_event.TimeChangeDetailsEventRepositoryImpl; import us.dot.its.jpo.ode.api.models.IDCount; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.events.TimeChangeDetailsEvent; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class TimeChangeDetailsEventRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -62,7 +55,6 @@ public class TimeChangeDetailsEventRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new TimeChangeDetailsEventRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/OdeMapDataRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/OdeMapDataRepositoryImplTest.java index 23ffbecaa..3b2b26fb0 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/OdeMapDataRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/OdeMapDataRepositoryImplTest.java @@ -3,7 +3,6 @@ import org.junit.jupiter.api.Test; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -32,14 +31,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class OdeMapDataRepositoryImplTest { @MockitoSpyBean @@ -107,12 +105,12 @@ void testFindLatest() { OdeMessageFrameData event = new OdeMessageFrameData(); doReturn(event).when(mongoTemplate).findOne(any(Query.class), eq(OdeMessageFrameData.class), - anyString()); + anyString()); Page page = repository.findLatest(intersectionID, startTime, endTime); assertThat(page.getContent()).hasSize(1); verify(mongoTemplate).findOne(any(Query.class), eq(OdeMessageFrameData.class), - eq("OdeMapJson")); + eq("OdeMapJson")); } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/ProcessedMapRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/ProcessedMapRepositoryImplTest.java index 648bcdd42..51644d59c 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/ProcessedMapRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/map/ProcessedMapRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.locationtech.jts.geom.CoordinateXY; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -54,19 +53,18 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.client.DistinctIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ProcessedMapRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ActiveNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ActiveNotificationRepositoryImplTest.java index 1c3c0cd97..143c8b934 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ActiveNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ActiveNotificationRepositoryImplTest.java @@ -5,11 +5,11 @@ import org.junit.jupiter.api.Test; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -26,11 +26,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.IntersectionReferenceAlignmentNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.LaneDirectionOfTravelNotification; @@ -41,12 +36,10 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.app_health.KafkaStreamsAnomalyNotification; import us.dot.its.jpo.ode.api.accessors.notifications.active_notification.ActiveNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class ActiveNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -59,7 +52,6 @@ public class ActiveNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new ActiveNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ConnectionOfTravelNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ConnectionOfTravelNotificationRepositoryImplTest.java index e46ad91a5..f82979ad6 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ConnectionOfTravelNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/ConnectionOfTravelNotificationRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -46,16 +45,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ConnectionOfTravelNotificationRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/IntersectionReferenceAlignmentNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/IntersectionReferenceAlignmentNotificationRepositoryImplTest.java index 172b1e0ec..21d8e0a90 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/IntersectionReferenceAlignmentNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/IntersectionReferenceAlignmentNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -26,18 +26,10 @@ import us.dot.its.jpo.ode.api.accessors.notifications.intersection_reference_alignment_notification.IntersectionReferenceAlignmentNotificationRepositoryImpl; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.IntersectionReferenceAlignmentNotification; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class IntersectionReferenceAlignmentNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -54,7 +46,6 @@ public class IntersectionReferenceAlignmentNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new IntersectionReferenceAlignmentNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/LaneDirectionOfTravelNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/LaneDirectionOfTravelNotificationRepositoryImplTest.java index 1199980af..6868b8b46 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/LaneDirectionOfTravelNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/LaneDirectionOfTravelNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -26,18 +26,10 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.LaneDirectionOfTravelNotification; import us.dot.its.jpo.ode.api.accessors.notifications.lane_direction_of_travel_notification.LaneDirectionOfTravelNotificationRepositoryImpl; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class LaneDirectionOfTravelNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -54,7 +46,6 @@ public class LaneDirectionOfTravelNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new LaneDirectionOfTravelNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/MapBroadcastRateNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/MapBroadcastRateNotificationRepositoryImplTest.java index 070f3a6a9..b375c857d 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/MapBroadcastRateNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/MapBroadcastRateNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -25,19 +25,12 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.broadcast_rate.MapBroadcastRateNotification; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.ode.api.accessors.notifications.map_broadcast_rate_notification.MapBroadcastRateNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class MapBroadcastRateNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -54,7 +47,6 @@ public class MapBroadcastRateNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new MapBroadcastRateNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalGroupAlignmentNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalGroupAlignmentNotificationRepositoryImplTest.java index b47bcc36f..45c84e5f4 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalGroupAlignmentNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalGroupAlignmentNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,20 +23,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.SignalGroupAlignmentNotification; import us.dot.its.jpo.ode.api.accessors.notifications.signal_group_alignment_notification.SignalGroupAlignmentNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class SignalGroupAlignmentNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -53,7 +46,6 @@ public class SignalGroupAlignmentNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new SignalGroupAlignmentNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalStateConflictNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalStateConflictNotificationRepositoryImplTest.java index b7727ab10..bc1952adb 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalStateConflictNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SignalStateConflictNotificationRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -39,20 +38,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.SignalStateConflictNotification; import us.dot.its.jpo.ode.api.accessors.notifications.signal_state_conflict_notification.SignalStateConflictNotificationRepositoryImpl; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class SignalStateConflictNotificationRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SpatBroadcastRateNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SpatBroadcastRateNotificationRepositoryImplTest.java index 6fd5f6781..d57d4cbe1 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SpatBroadcastRateNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/SpatBroadcastRateNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,20 +23,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.broadcast_rate.SpatBroadcastRateNotification; import us.dot.its.jpo.ode.api.accessors.notifications.spat_broadcast_rate_notification.SpatBroadcastRateNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class SpatBroadcastRateNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -53,7 +46,6 @@ public class SpatBroadcastRateNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new SpatBroadcastRateNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLinePassageNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLinePassageNotificationRepositoryImplTest.java index ebb3034ba..03430537d 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLinePassageNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLinePassageNotificationRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -39,20 +38,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.StopLinePassageNotification; import us.dot.its.jpo.ode.api.accessors.notifications.stop_line_passage_notification.StopLinePassageNotificationRepositoryImpl; import us.dot.its.jpo.ode.api.models.AggregationResult; import us.dot.its.jpo.ode.api.models.AggregationResultCount; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class StopLinePassageNotificationRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLineStopNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLineStopNotificationRepositoryImplTest.java index 1ea30d8b6..2b0acbe5d 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLineStopNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/StopLineStopNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,20 +23,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.StopLineStopNotification; import us.dot.its.jpo.ode.api.accessors.notifications.stop_line_stop_notification.StopLineStopNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class StopLineStopNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -53,7 +46,6 @@ public class StopLineStopNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new StopLineStopNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/TimeChangeDetailsNotificationRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/TimeChangeDetailsNotificationRepositoryImplTest.java index cd900de4d..24848aa7d 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/TimeChangeDetailsNotificationRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/notifications/TimeChangeDetailsNotificationRepositoryImplTest.java @@ -2,11 +2,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -23,20 +23,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.TimeChangeDetailsNotification; import us.dot.its.jpo.ode.api.accessors.notifications.time_change_details_notification.TimeChangeDetailsNotificationRepositoryImpl; -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ExtendWith(MockitoExtension.class) public class TimeChangeDetailsNotificationRepositoryImplTest { + @Mock private MongoTemplate mongoTemplate; @@ -53,7 +46,6 @@ public class TimeChangeDetailsNotificationRepositoryImplTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); repository = new TimeChangeDetailsNotificationRepositoryImpl(mongoTemplate); } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/OdeSpatDataRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/OdeSpatDataRepositoryImplTest.java index b32a4e459..4a2921853 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/OdeSpatDataRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/OdeSpatDataRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -26,14 +25,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class OdeSpatDataRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/ProcessedSpatRepositoryImplTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/ProcessedSpatRepositoryImplTest.java index 7c81e5f63..82aa53033 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/ProcessedSpatRepositoryImplTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/accessorTests/spat/ProcessedSpatRepositoryImplTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; -import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -45,16 +44,15 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.junit4.SpringRunner; import com.fasterxml.jackson.databind.ObjectMapper; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ProcessedSpatRepositoryImplTest { @MockitoSpyBean diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/BsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/BsmDecoderTests.java new file mode 100644 index 000000000..3a6cb396e --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/BsmDecoderTests.java @@ -0,0 +1,81 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.geojsonconverter.DateJsonMapper; +import us.dot.its.jpo.geojsonconverter.pojos.geojson.Point; +import us.dot.its.jpo.geojsonconverter.pojos.geojson.bsm.ProcessedBsm; +import us.dot.its.jpo.geojsonconverter.validator.BsmJsonValidator; +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class BsmDecoderTests { + + private BsmDecoder bsmDecoder; + + private String odeBsmDecodedXmlReference; + private String odeBsmDecodedJsonReference; + private String processedBsmReference; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + bsmDecoder = new BsmDecoder(messageFrameCodec, new BsmJsonValidator()); + + odeBsmDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceBsmXER.xml"))); + + odeBsmDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/bsm/Ode.ReferenceBsmJson.json"))); + + processedBsmReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/bsm/GJC.ReferenceProcessedBsmJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData bsm = bsmDecoder.convertXERToMessageFrame(odeBsmDecodedXmlReference); + + bsm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + bsm.getMetadata() + .setSerialId(bsm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeBsmDecodedJsonReference).isEqualTo(bsm.toJson()); + } + + /** + * Test to verify Conversion from a OdeMessageFrame object to a ProcessedBSM Object + */ + @Test + public void testConvertMessageFrameToProcessedBsm() throws JsonProcessingException { + OdeMessageFrameData bsmMessageFrame = objectMapper.readValue(odeBsmDecodedJsonReference, + OdeMessageFrameData.class); + + ProcessedBsm bsm = bsmDecoder.convertMessageFrameToProcessedBsm(bsmMessageFrame); + + bsm.getProperties().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + + assertThatJson(processedBsmReference).isEqualTo(bsm.toString()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/MapDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/MapDecoderTests.java new file mode 100644 index 000000000..54696e79b --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/MapDecoderTests.java @@ -0,0 +1,82 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.ZonedDateTime; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.geojsonconverter.DateJsonMapper; +import us.dot.its.jpo.geojsonconverter.pojos.geojson.LineString; +import us.dot.its.jpo.geojsonconverter.pojos.geojson.map.ProcessedMap; +import us.dot.its.jpo.geojsonconverter.validator.MapJsonValidator; +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class MapDecoderTests { + + private MapDecoder mapDecoder; + + private String odeMapDecodedXmlReference; + private String odeMapDecodedJsonReference; + private String processedMapReference; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + mapDecoder = new MapDecoder(messageFrameCodec, new MapJsonValidator()); + + odeMapDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceMapXER.xml"))); + + odeMapDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/map/Ode.ReferenceMapJson.json"))); + + processedMapReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/map/GJC.ReferenceProcessedMapJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData spat = mapDecoder.convertXERToMessageFrame(odeMapDecodedXmlReference); + + spat.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + spat.getMetadata() + .setSerialId(spat.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeMapDecodedJsonReference).isEqualTo(spat.toJson()); + } + + /** + * Test to verify Conversion from a OdeMessageFrame object to a ProcessedMAP Object + */ + @Test + public void testConvertMessageFrameToProcessedMap() throws JsonProcessingException { + OdeMessageFrameData mapMessageFrame = objectMapper.readValue(odeMapDecodedJsonReference, + OdeMessageFrameData.class); + + ProcessedMap map = mapDecoder.convertMessageFrameToProcessedMap(mapMessageFrame); + + map.getProperties().setOdeReceivedAt(ZonedDateTime.parse("2025-08-29T16:09:34.416Z")); + + assertThatJson(processedMapReference).isEqualTo(map.toString()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/PsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/PsmDecoderTests.java new file mode 100644 index 000000000..349e53b91 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/PsmDecoderTests.java @@ -0,0 +1,55 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class PsmDecoderTests { + + private PsmDecoder psmDecoder; + + private String odePsmDecodedXmlReference; + private String odePsmDecodedJsonReference; + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + psmDecoder = new PsmDecoder(messageFrameCodec); + + odePsmDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferencePsmXER.xml"))); + + odePsmDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/psm/Ode.ReferencePsmJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData psm = psmDecoder.convertXERToMessageFrame(odePsmDecodedXmlReference); + + psm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + psm.getMetadata() + .setSerialId(psm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odePsmDecodedJsonReference).isEqualTo(psm.toJson()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SpatDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SpatDecoderTests.java new file mode 100644 index 000000000..d407dfad1 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SpatDecoderTests.java @@ -0,0 +1,82 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.geojsonconverter.DateJsonMapper; +import us.dot.its.jpo.geojsonconverter.pojos.spat.ProcessedSpat; +import us.dot.its.jpo.geojsonconverter.validator.SpatJsonValidator; +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class SpatDecoderTests { + + private SpatDecoder spatDecoder; + + private String odeSpatDecodedXmlReference; + private String odeSpatDecodedJsonReference; + private String processedSpatReference; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + spatDecoder = new SpatDecoder(messageFrameCodec, new SpatJsonValidator()); + + odeSpatDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSpatXER.xml"))); + + odeSpatDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/spat/Ode.ReferenceSpatJson.json"))); + + processedSpatReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/spat/GJC.ReferenceProcessedSpatJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData spat = spatDecoder.convertXERToMessageFrame(odeSpatDecodedXmlReference); + + spat.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + spat.getMetadata() + .setSerialId(spat.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeSpatDecodedJsonReference).isEqualTo(spat.toJson()); + } + + /** + * Test to verify Conversion from a OdeMessageFrame object to a ProcessedSPAT Object + */ + @Test + public void testConvertMessageFrameToProcessedSpat() throws JsonProcessingException { + OdeMessageFrameData spatMessageFrame = objectMapper.readValue(odeSpatDecodedJsonReference, + OdeMessageFrameData.class); + + spatMessageFrame.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + + ProcessedSpat spat = spatDecoder.convertMessageFrameToProcessedSpat(spatMessageFrame); + + spat.setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + + assertThatJson(processedSpatReference).isEqualTo(spat.toString()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SrmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SrmDecoderTests.java new file mode 100644 index 000000000..9da74988a --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SrmDecoderTests.java @@ -0,0 +1,56 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class SrmDecoderTests { + + private SrmDecoder srmDecoder; + + private String odeSrmDecodedXmlReference; + private String odeSrmDecodedJsonReference; + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + srmDecoder = new SrmDecoder(messageFrameCodec); + + odeSrmDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSrmXER.xml"))); + + odeSrmDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/srm/Ode.ReferenceSrmJson.json"))) + .replaceAll("\n", "").replaceAll(" ", ""); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData srm = srmDecoder.convertXERToMessageFrame(odeSrmDecodedXmlReference); + + srm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + srm.getMetadata() + .setSerialId(srm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeSrmDecodedJsonReference).isEqualTo(srm.toJson()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SsmDecoderTests.java new file mode 100644 index 000000000..067f015b8 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/SsmDecoderTests.java @@ -0,0 +1,55 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class SsmDecoderTests { + + private SsmDecoder ssmDecoder; + + private String odeSsmDecodedXmlReference; + private String odeSsmDecodedJsonReference; + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + ssmDecoder = new SsmDecoder(messageFrameCodec); + + odeSsmDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSsmXER.xml"))); + + odeSsmDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/ssm/Ode.ReferenceSsmJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData ssm = ssmDecoder.convertXERToMessageFrame(odeSsmDecodedXmlReference); + + ssm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + ssm.getMetadata() + .setSerialId(ssm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeSsmDecodedJsonReference).isEqualTo(ssm.toJson()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/TimDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/TimDecoderTests.java new file mode 100644 index 000000000..264a232f0 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/asn1/TimDecoderTests.java @@ -0,0 +1,55 @@ +package us.dot.its.jpo.ode.api.asn1; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import j2735ffm.MessageFrameCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import us.dot.its.jpo.ode.model.OdeMessageFrameData; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +@ExtendWith(MockitoExtension.class) +public class TimDecoderTests { + + private TimDecoder timDecoder; + + private String odeTimDecodedXmlReference; + private String odeTimDecodedJsonReference; + + @Mock + MessageFrameCodec messageFrameCodec; + + @BeforeEach + void setUp() throws IOException { + timDecoder = new TimDecoder(messageFrameCodec); + + odeTimDecodedXmlReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceTimXER.xml"))); + + odeTimDecodedJsonReference = new String( + Files.readAllBytes(Paths.get("src/test/resources/json/tim/Ode.ReferenceTimJson.json"))); + } + + /** + * Test verifying the conversion from String XML data to OdeMessageFrame Object + */ + @Test + public void testGetAsMessageFrame() throws JsonProcessingException { + OdeMessageFrameData tim = timDecoder.convertXERToMessageFrame(odeTimDecodedXmlReference); + + tim.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); + tim.getMetadata() + .setSerialId(tim.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); + + assertThatJson(odeTimDecodedJsonReference).isEqualTo(tim.toJson()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailControllerTest.java new file mode 100644 index 000000000..509ad5b70 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailControllerTest.java @@ -0,0 +1,528 @@ +package us.dot.its.jpo.ode.api.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; +import us.dot.its.jpo.ode.api.models.emails.contents.ApiErrorEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.IntersectionNotificationSummaryEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.RsuErrorSummaryEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.SupportRequestEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountCountsItem; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountRsuItem; +import us.dot.its.jpo.ode.api.services.EmailService; +import us.dot.its.jpo.ode.api.services.PermissionService; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { "enable.api=true", + "enable.email=true" }) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class EmailControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private EmailService emailService; + + @MockitoBean + private PermissionService permissionService; + + private static final List SUCCESS = List.of(new EmailSendResponse(0, "OK")); + + @BeforeEach + void setUp() { + when(emailService.sendIntersectionNotificationSummaryEmailSendResponses(any())).thenReturn(SUCCESS); + when(emailService.sendMessageCounts(any())).thenReturn(SUCCESS); + when(emailService.sendFirmwareUpgradeFailure(any())).thenReturn(SUCCESS); + when(emailService.sendApiError(any())).thenReturn(SUCCESS); + when(emailService.sendRsuErrorSummary(any())).thenReturn(SUCCESS); + when(emailService.sendSupportRequest(any())).thenReturn(SUCCESS); + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/intersection-notifications + // @PreAuthorize: @PermissionService.isSuperUser() || + // @PermissionService.hasRole('USER') + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/intersection-notifications") + class IntersectionNotifications { + + @Test + @DisplayName("returns 403 when unauthenticated") + void unauthenticated_returns403() throws Exception { + mockMvc.perform(post("/emails/intersection-notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validIntersectionNotificationBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(post("/emails/intersection-notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validIntersectionNotificationBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when isSuperUser returns true") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/intersection-notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validIntersectionNotificationBody()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when hasRole('USER') returns true") + void userRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + mockMvc.perform(post("/emails/intersection-notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validIntersectionNotificationBody()))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when notifications field is null") + void nullNotifications_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/intersection-notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/message-counts + // @PreAuthorize: @PermissionService.isSuperUser() || + // hasRole('ROLE_SEND_MESSAGE_COUNTS_EMAILS') + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/message-counts") + class MessageCounts { + + @Test + @DisplayName("returns 403 when unauthenticated") + void unauthenticated_returns403() throws Exception { + mockMvc.perform(post("/emails/message-counts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validMessageCountBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but does not have the required role") + void insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/message-counts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validMessageCountBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when isSuperUser returns true") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/message-counts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validMessageCountBody()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser(roles = "SEND_MESSAGE_COUNTS_EMAILS") + @DisplayName("returns 200 when user has ROLE_SEND_MESSAGE_COUNTS_EMAILS") + void specificRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/message-counts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validMessageCountBody()))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when required fields are missing") + void invalidBody_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/message-counts") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/firmware-upgrade-failures + // @PreAuthorize: @PermissionService.isSuperUser() || + // hasRole('ROLE_SEND_FIRMWARE_UPGRADE_EMAILS') + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/firmware-upgrade-failures") + class FirmwareUpgradeFailures { + + @Test + @DisplayName("returns 403 when unauthenticated") + void unauthenticated_returns403() throws Exception { + mockMvc.perform(post("/emails/firmware-upgrade-failures") + .contentType(MediaType.APPLICATION_JSON) + .content(validFirmwareUpgradeJson())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but does not have the required role") + void insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/firmware-upgrade-failures") + .contentType(MediaType.APPLICATION_JSON) + .content(validFirmwareUpgradeJson())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when isSuperUser returns true") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/firmware-upgrade-failures") + .contentType(MediaType.APPLICATION_JSON) + .content(validFirmwareUpgradeJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser(roles = "SEND_FIRMWARE_UPGRADE_EMAILS") + @DisplayName("returns 200 when user has ROLE_SEND_FIRMWARE_UPGRADE_EMAILS") + void specificRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/firmware-upgrade-failures") + .contentType(MediaType.APPLICATION_JSON) + .content(validFirmwareUpgradeJson())) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when required fields are missing") + void invalidBody_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/firmware-upgrade-failures") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/api-errors + // @PreAuthorize: @PermissionService.isSuperUser() || + // hasRole('ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS') + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/api-errors") + class ApiErrors { + + @Test + @DisplayName("returns 403 when unauthenticated") + void unauthenticated_returns403() throws Exception { + mockMvc.perform(post("/emails/api-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validApiErrorBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but does not have the required role") + void insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/api-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validApiErrorBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when isSuperUser returns true") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/api-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validApiErrorBody()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser(roles = "SEND_CRITICAL_ERROR_MESSAGE_EMAILS") + @DisplayName("returns 200 when user has ROLE_SEND_CRITICAL_ERROR_MESSAGE_EMAILS") + void specificRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + + mockMvc.perform(post("/emails/api-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validApiErrorBody()))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when required fields are missing") + void invalidBody_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/api-errors") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/rsu-errors + // @PreAuthorize: @PermissionService.isSuperUser() || + // @PermissionService.hasRole('USER') + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/rsu-errors") + class RsuErrors { + + @Test + @DisplayName("returns 403 when unauthenticated") + void unauthenticated_returns403() throws Exception { + mockMvc.perform(post("/emails/rsu-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRsuErrorBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(post("/emails/rsu-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRsuErrorBody()))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when isSuperUser returns true") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/rsu-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRsuErrorBody()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when hasRole('USER') returns true") + void userRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + mockMvc.perform(post("/emails/rsu-errors") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRsuErrorBody()))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when required fields are missing") + void invalidBody_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/emails/rsu-errors") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // POST /emails/support-requests (no @PreAuthorize — open to any caller) + // ────────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /emails/support-requests") + class SupportRequests { + + @Test + @WithMockUser + @DisplayName("returns 200 for any authenticated caller") + void authenticated_returns200() throws Exception { + mockMvc.perform(post("/emails/support-requests") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSupportRequestBody()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(0)); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when required fields are missing") + void missingBody_returns400() throws Exception { + mockMvc.perform(post("/emails/support-requests") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when email field is blank") + void blankEmail_returns400() throws Exception { + SupportRequestEmailContents body = new SupportRequestEmailContents("", "Subject", "Message body"); + + mockMvc.perform(post("/emails/support-requests") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()); + } + } + + // ── Request body helpers ────────────────────────────────────────────────── + + private IntersectionNotificationSummaryEmailContents validIntersectionNotificationBody() { + // ConnectionOfTravelNotification is a concrete Notification subtype safe to + // instantiate + return new IntersectionNotificationSummaryEmailContents( + List.of(new ConnectionOfTravelNotification())); + } + + private MessageCountEmailContents validMessageCountBody() { + MessageCountCountsItem counts = new MessageCountCountsItem(); // int/double fields default to 0 + + MessageCountRsuItem rsuItem = new MessageCountRsuItem(); + rsuItem.setRsuIp("192.168.1.1"); + rsuItem.setPrimaryRoute("C470"); + rsuItem.setMessageCountsByType(Map.of("BSM", counts)); + + MessageCountEmailContents body = new MessageCountEmailContents(); + body.setOrganizationName("TestOrg"); + body.setDeploymentTitle("TestDeployment"); + body.setStartDate(Instant.parse("2024-01-01T00:00:00Z")); + body.setEndDate(Instant.parse("2024-01-02T00:00:00Z")); + body.setMessageTypeList(List.of("BSM")); + body.setRsuCounts(List.of(rsuItem)); + return body; + } + + /** + * FirmwareUpgradeFailureEmailContents uses @Data only (no @AllArgsConstructor), + * so raw JSON is the most concise way to build a valid instance. + */ + private String validFirmwareUpgradeJson() { + return """ + { + "rsu_ip": "192.168.1.1", + "message": "Firmware upgrade failed", + "failure_type": "Yunex Firmware Upgrade Error", + "stack_trace": "java.lang.RuntimeException: upgrade failed\\n\\tat com.example.Foo.bar(Foo.java:42)" + } + """; + } + + private ApiErrorEmailContents validApiErrorBody() { + return new ApiErrorEmailContents( + "NullPointerException", + "java.lang.NullPointerException\\n\\tat com.example.Foo.bar(Foo.java:10)", + Instant.parse("2024-01-01T12:00:00Z"), + "https://logs.example.com/errors/123"); + } + + private RsuErrorSummaryEmailContents validRsuErrorBody() { + return new RsuErrorSummaryEmailContents("RSU Error Summary", "Several RSUs reported errors."); + } + + private SupportRequestEmailContents validSupportRequestBody() { + return new SupportRequestEmailContents("user@example.com", "Help needed", "I need help with X."); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailRateLimitTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailRateLimitTest.java new file mode 100644 index 000000000..b67d7038b --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/EmailRateLimitTest.java @@ -0,0 +1,234 @@ +package us.dot.its.jpo.ode.api.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; +import us.dot.its.jpo.ode.api.models.emails.contents.SupportRequestEmailContents; +import us.dot.its.jpo.ode.api.services.EmailService; +import us.dot.its.jpo.ode.api.services.PermissionService; + +/** + * Verifies Bucket4j rate limiting on /emails/* endpoints: + * - Per-user limit: EMAIL_RATE_LIMIT_PER_USER req/hr, keyed on Authorization + * header + * - Global limit: EMAIL_RATE_LIMIT_PER_INSTANCE req/hr across all callers + * + * Limits are overridden to 3 per-user / 6 global for fast test execution. + * The Caffeine cache is cleared before each test for a fresh bucket state. + * + * Uses /emails/support-requests (no @PreAuthorize) + jwt() for Spring Security, + * plus an explicit Authorization header that the Bucket4j expression reads for + * per-user keying. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { + "enable.api=true", + "enable.email=true", + "bucket4j.enabled=true", + "spring.cache.cache-names=email-rate-limit", +}) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class EmailRateLimitTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private EmailService emailService; + + @MockitoBean + private PermissionService permissionService; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @BeforeEach + void setUp() { + Cache cache = cacheManager.getCache("email-rate-limit"); + if (cache != null) { + cache.clear(); + } + when(emailService.sendSupportRequest(any())) + .thenReturn(List.of(new EmailSendResponse(0, "OK"))); + // Default: decode any token and return a Jwt whose subject equals the token + // value. + // Individual tests that need custom subjects will re-stub this. + when(jwtDecoder.decode(anyString())).thenAnswer(invocation -> { + String token = invocation.getArgument(0); + if (token == null || token.isEmpty()) + return null; + return Jwt.withTokenValue(token) + .headers(h -> h.put("alg", "none")) + .claims(c -> { + c.put("sub", token); + c.put("iat", Instant.now()); + c.put("exp", Instant.now().plusSeconds(3600)); + }) + .build(); + }); + } + + // ── Per-user limit ──────────────────────────────────────────────────────── + + @Test + @DisplayName("Requests up to the per-user limit (3) all succeed") + void testRequestsBelowPerUserLimitSucceed() throws Exception { + for (int i = 0; i < 3; i++) { + postEmail("user-a").andExpect(status().isOk()); + } + } + + @Test + @DisplayName("The request immediately after the per-user limit (4th) returns 429") + void testPerUserLimitExceededReturns429() throws Exception { + for (int i = 0; i < 3; i++) { + postEmail("user-b"); + } + postEmail("user-b").andExpect(status().isTooManyRequests()); + } + + @Test + @DisplayName("Exhausting one user's bucket does not affect a different user's bucket") + void testDifferentUsersHaveSeparateBuckets() throws Exception { + for (int i = 0; i < 3; i++) { + postEmail("user-c"); + } + postEmail("user-c").andExpect(status().isTooManyRequests()); + + // user-d still has a full bucket + postEmail("user-d").andExpect(status().isOk()); + } + + @Test + @DisplayName("Different tokens with the same subject share the same per-user bucket") + void testDifferentTokensSameSubjectShareBucket() throws Exception { + // Arrange: make jwtDecoder return the same subject for two different token + // values + when(jwtDecoder.decode(anyString())).thenAnswer(invocation -> { + String token = invocation.getArgument(0); + if ("tokenA".equals(token) || "tokenB".equals(token)) { + return Jwt.withTokenValue(token) + .headers(h -> h.put("alg", "none")) + .claims(c -> { + c.put("sub", "shared-subject"); + c.put("iat", Instant.now()); + c.put("exp", Instant.now().plusSeconds(3600)); + }) + .build(); + } + // Fall back to the default behavior for other tokens + if (token == null || token.isEmpty()) + return null; + return Jwt.withTokenValue(token) + .headers(h -> h.put("alg", "none")) + .claims(c -> { + c.put("sub", token); + c.put("iat", Instant.now()); + c.put("exp", Instant.now().plusSeconds(3600)); + }) + .build(); + }); + + // Act: consume the per-user bucket using tokenA + for (int i = 0; i < 3; i++) { + mockMvc.perform(post("/emails/support-requests") + .header("Authorization", "Bearer tokenA") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SupportRequestEmailContents("a@b.com", "s", "m")))) + .andExpect(status().isOk()); + } + + // Assert: a request using tokenB (same subject) is rejected due to shared + // bucket exhaustion + mockMvc.perform(post("/emails/support-requests") + .header("Authorization", "Bearer tokenB") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SupportRequestEmailContents("a@b.com", "s", "m")))) + .andExpect(status().isTooManyRequests()); + } + + // ── Global limit ────────────────────────────────────────────────────────── + + @Test + @DisplayName("The request immediately after the global limit (7th) returns 429 regardless of user") + void testGlobalLimitExceededReturns429() throws Exception { + // user-e consumes 3 global tokens + for (int i = 0; i < 3; i++) { + postEmail("user-e"); + } + // user-f consumes the remaining 3 global tokens (total = 6) + for (int i = 0; i < 3; i++) { + postEmail("user-f"); + } + + // user-g has a fresh per-user bucket but the global bucket is empty + postEmail("user-g").andExpect(status().isTooManyRequests()); + } + + @Test + @DisplayName("A brand-new user with no prior requests is blocked when the global limit is exhausted") + void testGlobalLimitBlocksUserWithRemainingPersonalTokens() throws Exception { + for (int i = 0; i < 3; i++) { + postEmail("user-h"); + } + for (int i = 0; i < 3; i++) { + postEmail("user-i"); + } + // user-j has never made a request (full per-user bucket), but global is empty + postEmail("user-j").andExpect(status().isTooManyRequests()); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + /** + * jwt() satisfies Spring Security without a real JWT signature. + * The explicit Authorization header is what the Bucket4j cache-key expression + * reads — Spring Security ignores it because authentication is already + * established via the test SecurityContext. + */ + private ResultActions postEmail(String userKey) throws Exception { + SupportRequestEmailContents body = new SupportRequestEmailContents( + "tester@example.com", "Test Subject", "Test message body"); + when(permissionService.isSuperUser()).thenReturn(true); + + return mockMvc.perform(post("/emails/support-requests") + .header("Authorization", "Bearer " + userKey) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/admin/AdminIntersectionControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/admin/AdminIntersectionControllerTest.java new file mode 100644 index 000000000..71b10db65 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/admin/AdminIntersectionControllerTest.java @@ -0,0 +1,1037 @@ +package us.dot.its.jpo.ode.api.controllers.admin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.persistence.EntityNotFoundException; + +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.admin.intersection.AllowedSelections; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionCreate; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionDto; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionListResponse; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionPatch; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionSingleResponse; +import us.dot.its.jpo.ode.api.models.admin.intersection.RefPt; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.repositories.IntersectionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; +import us.dot.its.jpo.ode.api.services.AdminIntersectionService; +import us.dot.its.jpo.ode.api.services.PermissionService; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + *

+ * Tests the full HTTP request/response cycle including: + *

    + *
  • Authorization: {@code @PreAuthorize} enforcement via the mocked + * {@link PermissionService}
  • + *
  • In-method org restriction enforcement (PATCH endpoint)
  • + *
  • Bean Validation: {@code @NotNull}/{@code @NotBlank} constraints on + * request body and params
  • + *
  • Response shape: JSON field names, types, and values
  • + *
  • Service delegation: verifies the correct service method is called with + * the right arguments
  • + *
+ * + *

+ * Security note: + * {@link us.dot.its.jpo.ode.api.controllers.advice.GlobalExceptionHandler} + * intercepts {@link org.springframework.security.access.AccessDeniedException} + * (thrown by + * {@code @PreAuthorize} when it evaluates to {@code false}) and returns HTTP + * 403. As a result, + * both unauthenticated requests and authenticated-but-unauthorized requests + * consistently yield 403. + * + *

+ * Validation ordering note: {@code @RequestBody @Validated} constraints + * are resolved + * during Spring MVC argument binding — before the {@code @PreAuthorize} AOP + * advice runs. Validation + * failures therefore return 400 regardless of the caller's permissions. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class AdminIntersectionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private PermissionService permissionService; + + @MockitoBean + private AdminIntersectionService adminIntersectionService; + + @MockitoBean + IntersectionRepository intersectionRepository; + + @MockitoBean + RsuRepository rsuRepository; + + /** + * Concrete-class mock; created fresh per test to avoid cross-test stubbing + * bleed. + */ + private CvManagerAuthToken authToken; + + private IntersectionListResponse sampleListResponse; + private IntersectionSingleResponse sampleSingleResponse; + private IntersectionPatch validPatch; + private IntersectionCreate validCreate; + private AllowedSelections sampleAllowedSelections; + + @BeforeEach + void setUp() { + authToken = Mockito.mock(CvManagerAuthToken.class); + + RefPt refPt = new RefPt(39.7392, -104.9903); + + IntersectionDto sampleDto = new IntersectionDto( + 12109, + refPt, + null, + "Main St & 1st Ave", + "192.168.1.1", + List.of("TestOrg"), + List.of("10.0.0.1")); + + AllowedSelections allowedSelections = new AllowedSelections( + List.of("TestOrg", "OtherOrg"), + List.of("10.0.0.1", "10.0.0.2")); + + sampleListResponse = new IntersectionListResponse(List.of(sampleDto)); + sampleSingleResponse = new IntersectionSingleResponse(sampleDto, allowedSelections); + + validPatch = new IntersectionPatch( + 12109, 12109, refPt, + null, "Main St & 1st Ave", null, + List.of(), List.of(), List.of(), List.of()); + + validCreate = new IntersectionCreate( + 12109, refPt, + List.of("TestOrg"), List.of(), + null, "Main St & 1st Ave", null); + + sampleAllowedSelections = new AllowedSelections( + List.of("TestOrg", "OtherOrg"), + List.of("10.0.0.1", "10.0.0.2")); + } + + @Nested + @DisplayName("GET /admin/intersections — list all intersections") + class GetAllIntersections { + + @Test + @DisplayName("returns 403 when no permissions are granted (unauthenticated)") + void noPermissions_returns403() throws Exception { + // Spring Security filter runs before argument binding; unauthenticated → 403 + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when Organization header is missing") + void missingOrganizationHeader_returns400() throws Exception { + // Argument binding runs before @PreAuthorize; missing required header → 400 + mockMvc.perform(get("/admin/intersections")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with intersection list when isSuperUser returns true") + void superUser_returns200WithIntersectionList() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getAllIntersections(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data").isArray()) + .andExpect(jsonPath("$.intersection_data[0].intersection_id").value(12109)) + .andExpect(jsonPath("$.intersection_data[0].intersection_name").value("Main St & 1st Ave")) + .andExpect(jsonPath("$.intersection_data[0].organizations[0]").value("TestOrg")); + } + + @Test + @WithMockUser + @DisplayName("returns 200 when hasRole('USER') returns true (non-superuser path)") + void userWithRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(adminIntersectionService.getAllIntersections(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data[0].intersection_id").value(12109)); + } + + @Test + @WithMockUser + @DisplayName("passes the Organization header value to the service") + void organizationHeader_isForwardedToService() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getAllIntersections(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()); + + verify(adminIntersectionService).getAllIntersections(eq("TestOrg")); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with empty list when no intersections are found for the organization") + void noAccessibleIntersections_returns200WithEmptyList() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getAllIntersections(any())) + .thenReturn(new IntersectionListResponse(List.of())); + + mockMvc.perform(get("/admin/intersections") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data").isArray()) + .andExpect(jsonPath("$.intersection_data").isEmpty()); + } + } + + @Nested + @DisplayName("GET /admin/intersections/{intersectionId} — single intersection") + class GetSingleIntersection { + + @Test + @DisplayName("returns 403 when no permissions are granted") + void noPermissions_returns403() throws Exception { + mockMvc.perform(get("/admin/intersections/12109")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(get("/admin/intersections/12109")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with intersection_data and allowed_selections for super user") + void superUser_returns200WithFullResponseShape() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(Collections.emptyList()); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(Collections.emptyList()); + when(adminIntersectionService.getIntersection(eq(12109))).thenReturn(sampleSingleResponse); + + mockMvc.perform(get("/admin/intersections/12109")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data.intersection_id").value(12109)) + .andExpect(jsonPath("$.allowed_selections").exists()) + .andExpect(jsonPath("$.allowed_selections.organizations").isArray()) + .andExpect(jsonPath("$.allowed_selections.rsus").isArray()); + } + + @Test + @WithMockUser + @DisplayName("returns 404 when intersection is not found") + void intersectionNotFound_returns404() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(Collections.emptyList()); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(Collections.emptyList()); + doThrow(new EntityNotFoundException("Intersection with id 99999 not found")) + .when(adminIntersectionService).getIntersection(99999); + + mockMvc.perform(get("/admin/intersections/99999")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser + @DisplayName("non-superuser with USER role and intersection access returns 200") + void organizationHeader_passedToServiceWithCorrectOrgLists() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("USER"))).thenReturn(true); + when(adminIntersectionService.getIntersection(12109)).thenReturn(sampleSingleResponse); + + mockMvc.perform(get("/admin/intersections/12109") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()); + + verify(adminIntersectionService).getIntersection(eq(12109)); + } + } + + @Nested + @DisplayName("PATCH /admin/intersections — update intersection") + class PatchIntersection { + + @Test + @DisplayName("returns 403 when no permissions are granted") + void noPermissions_returns403() throws Exception { + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPatch))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but hasRole('OPERATOR') returns false") + void noOperatorRole_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(false); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPatch))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when operator role is granted but hasIntersection returns false") + void operatorWithoutIntersectionAccess_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(false); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPatch))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when organizations_to_add contains an org outside the user's qualified orgs") + void unqualifiedOrgInOrganizationsToAdd_returns403() throws Exception { + IntersectionPatch patchWithUnqualifiedOrg = new IntersectionPatch( + 12109, 12109, new RefPt(39.7392, -104.9903), + null, null, null, + List.of("UnqualifiedOrg"), List.of(), List.of(), List.of()); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(patchWithUnqualifiedOrg))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).patchIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when organizations_to_remove contains an org outside the user's qualified orgs") + void unqualifiedOrgInOrganizationsToRemove_returns403() throws Exception { + IntersectionPatch patchWithUnqualifiedOrgRemove = new IntersectionPatch( + 12109, 12109, new RefPt(39.7392, -104.9903), + null, null, null, + List.of(), List.of("UnqualifiedOrg"), List.of(), List.of()); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(patchWithUnqualifiedOrgRemove))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).patchIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when rsus_to_add contains an RSU outside the user's qualified RSUs") + void unqualifiedRsuInRsusToAdd_returns403() throws Exception { + IntersectionPatch patchWithUnqualifiedRsu = new IntersectionPatch( + 12109, 12109, new RefPt(39.7392, -104.9903), + null, null, null, + List.of(), List.of(), List.of("192.168.1.99"), List.of()); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(permissionService.hasRsus(eq(List.of("192.168.1.99")), eq("OPERATOR"))).thenReturn(false); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(patchWithUnqualifiedRsu))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).patchIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when rsus_to_remove contains an RSU outside the user's qualified RSUs") + void unqualifiedRsuInRsusToRemove_returns403() throws Exception { + IntersectionPatch patchWithUnqualifiedRsuRemove = new IntersectionPatch( + 12109, 12109, new RefPt(39.7392, -104.9903), + null, null, null, + List.of(), List.of(), List.of(), List.of("192.168.1.99")); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(permissionService.hasRsus(eq(List.of()), eq("OPERATOR"))).thenReturn(true); + when(permissionService.hasRsus(eq(List.of("192.168.1.99")), eq("OPERATOR"))).thenReturn(false); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(patchWithUnqualifiedRsuRemove))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).patchIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("super user bypasses org restriction check entirely") + void superUser_bypassesOrgRestriction_returns200() throws Exception { + IntersectionPatch patchWithAnyOrg = new IntersectionPatch( + 12109, 12109, new RefPt(39.7392, -104.9903), + null, null, null, + List.of("AnyOrgNotInQualifiedList"), List.of(), List.of(), List.of()); + + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(patchWithAnyOrg))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("returns 400 when orig_intersection_id is absent from request body") + void missingOrigIntersectionId_returns400() throws Exception { + // @RequestBody @Validated fires before @PreAuthorize — no auth stub needed + String bodyMissingOrigId = """ + { + "intersection_id": 12109, + "ref_pt": {"latitude": 39.7392, "longitude": -104.9903}, + "organizations_to_add": [], + "organizations_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [] + } + """; + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(bodyMissingOrigId)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when ref_pt is absent from request body") + void missingRefPt_returns400() throws Exception { + String bodyMissingRefPt = """ + { + "orig_intersection_id": 12109, + "intersection_id": 12109, + "organizations_to_add": [], + "organizations_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [] + } + """; + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(bodyMissingRefPt)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when organizations_to_add is explicitly null") + void nullOrganizationsToAdd_returns400() throws Exception { + String bodyWithNullOrgs = """ + { + "orig_intersection_id": 12109, + "intersection_id": 12109, + "ref_pt": {"latitude": 39.7392, "longitude": -104.9903}, + "organizations_to_add": null, + "organizations_to_remove": [], + "rsus_to_add": [], + "rsus_to_remove": [] + } + """; + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(bodyWithNullOrgs)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("qualified operator with intersection access returns 200 with success message") + void qualifiedOperator_returns200WithSuccessMessage() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(permissionService.hasRsus(anyList(), eq("OPERATOR"))).thenReturn(true); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPatch))) + .andExpect(status().isOk()); + + verify(adminIntersectionService).patchIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 404 when service throws ResponseStatusException with NOT_FOUND") + void serviceThrowsNotFound_returns404() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + doThrow(new ResponseStatusException( + HttpStatus.NOT_FOUND, "Intersection not found: 12109")).when(adminIntersectionService) + .patchIntersection(any()); + + mockMvc.perform(patch("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPatch))) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("DELETE /admin/intersections — delete intersection") + class DeleteIntersection { + + @Test + @DisplayName("returns 403 when no permissions are granted") + void noPermissions_returns403() throws Exception { + mockMvc.perform(delete("/admin/intersections/12109")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but hasRole('OPERATOR') returns false") + void noOperatorRole_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(false); + + mockMvc.perform(delete("/admin/intersections/12109")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when operator role is granted but hasIntersection returns false") + void operatorWithoutIntersectionAccess_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(false); + + mockMvc.perform(delete("/admin/intersections/12109")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("super user returns 200 with success message") + void superUser_returns200WithSuccessMessage() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(delete("/admin/intersections/12109")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("qualified operator with intersection access returns 200 with success message") + void qualifiedOperator_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.hasIntersection(eq(12109), eq("OPERATOR"))).thenReturn(true); + + mockMvc.perform(delete("/admin/intersections/12109")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 404 when service throws ResponseStatusException with NOT_FOUND") + void serviceThrowsNotFound_returns404() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + doThrow(new ResponseStatusException( + HttpStatus.NOT_FOUND, "Intersection not found: 99999")).when(adminIntersectionService) + .deleteIntersection(any()); + + mockMvc.perform(delete("/admin/intersections/99999")) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /admin/intersections/allowed-selections — get allowed selections for creating an intersection") + class GetAllowedSelections { + + @Test + @DisplayName("returns 403 when no permissions are granted") + void noPermissions_returns403() throws Exception { + mockMvc.perform(get("/admin/intersections/allowed-selections")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(get("/admin/intersections/allowed-selections")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("super user returns 200 with organizations and rsus") + void superUser_returns200WithAllowedSelections() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getAllowedSelections()).thenReturn(sampleAllowedSelections); + + mockMvc.perform(get("/admin/intersections/allowed-selections")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.organizations").isArray()) + .andExpect(jsonPath("$.organizations[0]").value("TestOrg")) + .andExpect(jsonPath("$.rsus").isArray()) + .andExpect(jsonPath("$.rsus[0]").value("10.0.0.1")); + } + + @Test + @WithMockUser + @DisplayName("non-superuser with USER role returns 200") + void userWithRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(adminIntersectionService.getAllowedSelections()).thenReturn(sampleAllowedSelections); + + mockMvc.perform(get("/admin/intersections/allowed-selections")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.organizations").isArray()); + + verify(adminIntersectionService).getAllowedSelections(); + } + } + + @Nested + @DisplayName("GET /admin/intersections/available — intersections not in organization") + class GetIntersectionsNotInOrganization { + + @Test + @DisplayName("returns 403 when no permissions are granted (unauthenticated)") + void noPermissions_returns403() throws Exception { + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 when Organization header is missing") + void missingOrganizationHeader_returns400() throws Exception { + mockMvc.perform(get("/admin/intersections/available")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('ADMIN')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.ADMIN)).thenReturn(false); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("super user returns 200 with intersection list") + void superUser_returns200WithIntersectionList() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getIntersectionsNotInOrganization(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data").isArray()) + .andExpect(jsonPath("$.intersection_data[0].intersection_id").value(12109)) + .andExpect(jsonPath("$.intersection_data[0].intersection_name").value("Main St & 1st Ave")) + .andExpect(jsonPath("$.intersection_data[0].rsus[0]").value("10.0.0.1")); + } + + @Test + @WithMockUser + @DisplayName("non-superuser with ADMIN role returns 200") + void adminRole_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.ADMIN)).thenReturn(true); + when(adminIntersectionService.getIntersectionsNotInOrganization(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data[0].intersection_id").value(12109)); + + verify(adminIntersectionService).getIntersectionsNotInOrganization(eq("TestOrg")); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with empty list when no intersections are outside the organization") + void emptyResult_returns200WithEmptyList() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getIntersectionsNotInOrganization(any())) + .thenReturn(new IntersectionListResponse(List.of())); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.intersection_data").isArray()) + .andExpect(jsonPath("$.intersection_data").isEmpty()); + } + + @Test + @WithMockUser + @DisplayName("passes the Organization header value to the service") + void organizationHeader_isForwardedToService() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getIntersectionsNotInOrganization(eq("TestOrg"))) + .thenReturn(sampleListResponse); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()); + + verify(adminIntersectionService).getIntersectionsNotInOrganization(eq("TestOrg")); + } + + @Test + @WithMockUser + @DisplayName("propagates service exception (500)") + void serviceThrows_returns500() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(adminIntersectionService.getIntersectionsNotInOrganization(eq("TestOrg"))) + .thenThrow(new RuntimeException("Database connection failed")); + + mockMvc.perform(get("/admin/intersections/available") + .header("Organization", "TestOrg")) + .andExpect(status().isInternalServerError()); + } + } + + @Nested + @DisplayName("POST /admin/intersections — create intersection") + class PostIntersection { + + @Test + @DisplayName("returns 403 when no permissions are granted") + void noPermissions_returns403() throws Exception { + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreate))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but hasRole('OPERATOR') returns false") + void noOperatorRole_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(false); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreate))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("super user bypasses org/RSU enforcement and returns 200") + void superUser_bypassesEnforcement_returns200() throws Exception { + IntersectionCreate createWithAnyOrg = new IntersectionCreate( + 12109, new RefPt(39.7392, -104.9903), + List.of("AnyOrg"), List.of("192.168.1.1"), + null, null, null); + + when(permissionService.isSuperUser()).thenReturn(true); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createWithAnyOrg))) + .andExpect(status().isOk()); + + verify(adminIntersectionService).createIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when non-superuser requests org outside qualified list") + void unqualifiedOrg_returns403() throws Exception { + IntersectionCreate createWithUnqualifiedOrg = new IntersectionCreate( + 12109, new RefPt(39.7392, -104.9903), + List.of("UnqualifiedOrg"), List.of(), + null, null, null); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createWithUnqualifiedOrg))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).createIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when non-superuser requests RSU outside qualified set") + void unqualifiedRsu_returns403() throws Exception { + IntersectionCreate createWithUnqualifiedRsu = new IntersectionCreate( + 12109, new RefPt(39.7392, -104.9903), + List.of("TestOrg"), List.of("192.168.1.99"), + null, null, null); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(permissionService.hasRsus(eq(List.of("192.168.1.99")), eq("OPERATOR"))).thenReturn(false); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createWithUnqualifiedRsu))) + .andExpect(status().isForbidden()); + + verify(adminIntersectionService, never()).createIntersection(any()); + } + + @Test + @WithMockUser + @DisplayName("qualified operator with valid orgs and RSUs returns 200") + void qualifiedOperator_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(permissionService.hasRsus(anyList(), eq("OPERATOR"))).thenReturn(true); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreate))) + .andExpect(status().isOk()); + + verify(adminIntersectionService).createIntersection(any()); + } + + @Test + @DisplayName("returns 400 when intersection_id is missing") + void missingIntersectionId_returns400() throws Exception { + String body = """ + { + "ref_pt": {"latitude": 39.7392, "longitude": -104.9903}, + "organizations": ["TestOrg"], + "rsus": [] + } + """; + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when ref_pt is missing") + void missingRefPt_returns400() throws Exception { + String body = """ + { + "intersection_id": 12109, + "organizations": ["TestOrg"], + "rsus": [] + } + """; + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when ref_pt.latitude is missing") + void missingRefPtLatitude_returns400() throws Exception { + String body = """ + { + "intersection_id": 12109, + "ref_pt": {"longitude": -104.9903}, + "organizations": ["TestOrg"], + "rsus": [] + } + """; + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when organizations list is empty") + void emptyOrganizations_returns400() throws Exception { + String body = """ + { + "intersection_id": 12109, + "ref_pt": {"latitude": 39.7392, "longitude": -104.9903}, + "organizations": [], + "rsus": [] + } + """; + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when rsus contains invalid IPv4") + void invalidIpv4InRsus_returns400() throws Exception { + String body = """ + { + "intersection_id": 12109, + "ref_pt": {"latitude": 39.7392, "longitude": -104.9903}, + "organizations": ["TestOrg"], + "rsus": ["not-an-ip"] + } + """; + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("valid request with empty rsus list returns 200") + void emptyRsusList_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + + IntersectionCreate createNoRsus = new IntersectionCreate( + 12109, new RefPt(39.7392, -104.9903), + List.of("TestOrg"), List.of(), + null, null, null); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createNoRsus))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 404 when service throws EntityNotFoundException") + void serviceThrowsNotFound_returns404() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + doThrow(new EntityNotFoundException("Organization(s) not found: [BadOrg]")) + .when(adminIntersectionService).createIntersection(any()); + + mockMvc.perform(post("/admin/intersections") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreate))) + .andExpect(status().isNotFound()); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/advice/GlobalExceptionHandlerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/advice/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..57fc842d8 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/advice/GlobalExceptionHandlerTest.java @@ -0,0 +1,625 @@ +package us.dot.its.jpo.ode.api.controllers.advice; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.server.ResponseStatusException; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; + +import us.dot.its.jpo.ode.api.services.RsuCredentialManagementService; +import us.dot.its.jpo.ode.api.services.RsuUpgradeService; +import us.dot.its.jpo.ode.api.services.SnmpCredentialManagementService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Nested; + +class GlobalExceptionHandlerTest { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Nested + class HandleEntityNotFoundTests { + + @Test + void testReturnsNotFound() { + EntityNotFoundException ex = new EntityNotFoundException("User not found"); + + ErrorResponse response = handler.handleEntityNotFound(ex); + + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("User not found")); + } + + @Test + void testWithDifferentMessage() { + EntityNotFoundException ex = new EntityNotFoundException("RSU with IP 192.168.1.1 not found"); + + ErrorResponse response = handler.handleEntityNotFound(ex); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("192.168.1.1")); + } + } + + @Nested + class HandleRsuCredentialAlreadyExistsExceptionTests { + + @Test + void testHandleRsuCredentialAlreadyExistsException() { + // Arrange + RsuCredentialManagementService.RsuCredentialAlreadyExistsException exception = new RsuCredentialManagementService.RsuCredentialAlreadyExistsException( + "RSU Credential already exists"); + + // Act + ProblemDetail problemDetail = handler.handleRsuCredentialAlreadyExistsException(exception); + + // Assert + assertNotNull(problemDetail); + assertEquals("RSU Credential already exists", problemDetail.getDetail()); + assertEquals(HttpStatus.CONFLICT.value(), problemDetail.getStatus()); + } + } + + @Nested + class HandleSnmpCredentialAlreadyExistsExceptionTests { + @Test + void testHandleSnmpCredentialAlreadyExistsException() { + // Arrange + SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException exception = new SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException( + "SNMP Credential already exists"); + + // Act + ProblemDetail problemDetail = handler.handleSnmpCredentialAlreadyExistsException(exception); + + // Assert + assertNotNull(problemDetail); + assertEquals("SNMP Credential already exists", problemDetail.getDetail()); + assertEquals(HttpStatus.CONFLICT.value(), problemDetail.getStatus()); + } + } + + @Nested + class HandleFirmwareUpgradeUnavailableExceptionTests { + @Test + void testHandleFirmwareUpgradeUnavailableException() { + RsuUpgradeService.FirmwareUpgradeUnavailableException exception = new RsuUpgradeService.FirmwareUpgradeUnavailableException( + "Requested RSU is already up to date"); + + ProblemDetail problemDetail = handler.handleFirmwareUpgradeUnavailableException(exception); + + assertNotNull(problemDetail); + assertEquals("Requested RSU is already up to date", problemDetail.getDetail()); + assertEquals(HttpStatus.CONFLICT.value(), problemDetail.getStatus()); + } + } + + @Nested + class HandleAccessDeniedExceptionTests { + + @Test + void testHandleAccessDeniedException() { + // Arrange + AccessDeniedException exception = new AccessDeniedException("Access denied"); + + // Act + ProblemDetail problemDetail = handler.handleAccessDeniedException(exception); + + // Assert + assertNotNull(problemDetail); + assertEquals("Access denied", problemDetail.getDetail()); + assertEquals(HttpStatus.FORBIDDEN.value(), problemDetail.getStatus()); + } + } + + @Nested + class HandleIllegalArgumentTests { + + @Test + void testInvalidIpAddress() { + IllegalArgumentException ex = new IllegalArgumentException("Invalid IP address: invalid-ip"); + + ErrorResponse response = handler.handleIllegalArgument(ex); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertTrue(body.getDetail().contains("Invalid IP address")); + assertTrue(body.getDetail().contains("invalid-ip")); + } + + @Test + void testInvalidParameter() { + IllegalArgumentException ex = new IllegalArgumentException( + "Parameter 'organizationName' cannot be null or empty"); + + ErrorResponse response = handler.handleIllegalArgument(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("organizationName")); + assertTrue(response.getBody().getDetail().contains("cannot be null or empty")); + } + + @Test + void testInvalidEnumValue() { + IllegalArgumentException ex = new IllegalArgumentException( + "Invalid model type: UNKNOWN. Valid values are: COMMSIGNIA, YUNEX"); + + ErrorResponse response = handler.handleIllegalArgument(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertTrue(body.getDetail().contains("Invalid model type")); + assertTrue(body.getDetail().contains("UNKNOWN")); + } + + @Test + void testGenericIllegalArgument() { + IllegalArgumentException ex = new IllegalArgumentException("Invalid input provided"); + + ErrorResponse response = handler.handleIllegalArgument(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Invalid input provided", response.getBody().getDetail()); + } + } + + @Nested + class HandleResponseStatusExceptionTests { + + @Test + void testNotFoundWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.NOT_FOUND, "RSU not found"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertEquals("RSU not found", response.getBody().getDetail()); + } + + @Test + void testBadRequestWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid request parameters"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Invalid request parameters", response.getBody().getDetail()); + } + + @Test + void testUnauthorizedWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authentication required"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertEquals("Authentication required", response.getBody().getDetail()); + } + + @Test + void testForbiddenWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.FORBIDDEN, + "Access denied to this resource"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + assertEquals("Access denied to this resource", response.getBody().getDetail()); + } + + @Test + void testConflictWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.CONFLICT, "Resource already exists"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals("Resource already exists", response.getBody().getDetail()); + } + + @Test + void testInternalServerErrorWithReason() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Database connection failed"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals("Database connection failed", response.getBody().getDetail()); + } + + @Test + void testWithoutReasonUsesDefaultStatusPhrase() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.NOT_FOUND); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + // Should use default HTTP status reason phrase + assertEquals("Not Found", response.getBody().getDetail()); + } + + @Test + void testServiceUnavailable() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Service temporarily unavailable"); + + ErrorResponse response = handler.handleResponseStatusException(ex); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + assertEquals("Service temporarily unavailable", response.getBody().getDetail()); + } + } + + @Nested + class HandleConstraintViolationTests { + + @Test + void testSingleViolation() { + ConstraintViolation violation = mock(ConstraintViolation.class); + Path path = mock(Path.class); + + when(violation.getPropertyPath()).thenReturn(path); + when(path.toString()).thenReturn("getRsu.id"); + when(violation.getMessage()).thenReturn("must be greater than 0"); + + ConstraintViolationException ex = new ConstraintViolationException(Set.of(violation)); + + ErrorResponse response = handler.handleConstraintViolation(ex); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertTrue(body.getDetail().contains("Validation failed")); + assertTrue(body.getDetail().contains("id")); + assertTrue(body.getDetail().contains("must be greater than 0")); + + @SuppressWarnings("unchecked") + Map violations = (Map) body.getProperties().get("violations"); + assertNotNull(violations); + assertEquals("must be greater than 0", violations.get("id")); + } + + @Test + void testMultipleViolations() { + ConstraintViolation violation1 = mock(ConstraintViolation.class); + ConstraintViolation violation2 = mock(ConstraintViolation.class); + Path path1 = mock(Path.class); + Path path2 = mock(Path.class); + + when(violation1.getPropertyPath()).thenReturn(path1); + when(path1.toString()).thenReturn("createRsu.rsuIp"); + when(violation1.getMessage()).thenReturn("must not be blank"); + + when(violation2.getPropertyPath()).thenReturn(path2); + when(path2.toString()).thenReturn("createRsu.limit"); + when(violation2.getMessage()).thenReturn("must be less than 100"); + + ConstraintViolationException ex = new ConstraintViolationException(Set.of(violation1, violation2)); + + ErrorResponse response = handler.handleConstraintViolation(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + @SuppressWarnings("unchecked") + Map violations = (Map) response.getBody().getProperties().get("violations"); + assertEquals(2, violations.size()); + } + } + + @Nested + class HandleMethodArgumentNotValidTests { + + @Test + void testSingleFieldError() { + MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); + BindingResult bindingResult = mock(BindingResult.class); + FieldError fieldError = new FieldError("rsuDto", "ipv4Address", "must not be null"); + + when(ex.getBindingResult()).thenReturn(bindingResult); + when(bindingResult.getFieldErrors()).thenReturn(java.util.List.of(fieldError)); + + ErrorResponse response = handler.handleMethodArgumentNotValid(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertTrue(body.getDetail().contains("Validation failed")); + assertTrue(body.getDetail().contains("ipv4Address")); + assertTrue(body.getDetail().contains("must not be null")); + + @SuppressWarnings("unchecked") + Map fieldErrors = (Map) body.getProperties().get("fieldErrors"); + assertEquals("must not be null", fieldErrors.get("ipv4Address")); + } + + @Test + void testMultipleFieldErrors() { + MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); + BindingResult bindingResult = mock(BindingResult.class); + FieldError error1 = new FieldError("userDto", "email", "must not be null"); + FieldError error2 = new FieldError("userDto", "firstName", "size must be between 1 and 128"); + + when(ex.getBindingResult()).thenReturn(bindingResult); + when(bindingResult.getFieldErrors()).thenReturn(java.util.List.of(error1, error2)); + + ErrorResponse response = handler.handleMethodArgumentNotValid(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + @SuppressWarnings("unchecked") + Map fieldErrors = (Map) response.getBody().getProperties() + .get("fieldErrors"); + assertEquals(2, fieldErrors.size()); + assertEquals("must not be null", fieldErrors.get("email")); + } + } + + @Nested + class HandleMissingRequestHeaderExceptionTests { + + @Test + void testReturnsBadRequestWithHeaderName() { + MissingRequestHeaderException ex = mock(MissingRequestHeaderException.class); + when(ex.getHeaderName()).thenReturn("X-Organization-Name"); + + ProblemDetail problemDetail = handler.handleMissingRequestHeaderException(ex); + + assertNotNull(problemDetail); + assertEquals(HttpStatus.BAD_REQUEST.value(), problemDetail.getStatus()); + assertEquals("Required request header 'X-Organization-Name' is not present", problemDetail.getDetail()); + assertEquals("Missing Header", problemDetail.getTitle()); + } + + @Test + void testWithDifferentHeaderName() { + MissingRequestHeaderException ex = mock(MissingRequestHeaderException.class); + when(ex.getHeaderName()).thenReturn("Authorization"); + + ProblemDetail problemDetail = handler.handleMissingRequestHeaderException(ex); + + assertEquals(HttpStatus.BAD_REQUEST.value(), problemDetail.getStatus()); + assertEquals("Required request header 'Authorization' is not present", problemDetail.getDetail()); + assertEquals("Missing Header", problemDetail.getTitle()); + } + } + + @Nested + class HandleDataIntegrityViolationTests { + + @Test + void testDuplicateKey() { + String errorMessage = "could not execute statement [ERROR: duplicate key value violates unique constraint \"rsu_milepost_primary_route\" " + + + "Detail: Key (milepost, primary_route)=(1, I999) already exists.] " + + "constraint [rsu_milepost_primary_route]"; + + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertTrue(body.getDetail().contains("RSU")); + assertTrue(body.getDetail().contains("milepost '1'")); + assertTrue(body.getDetail().contains("primary route 'I999'")); + assertTrue(body.getDetail().contains("already exists")); + assertFalse(body.getDetail().contains("SQL")); + assertFalse(body.getDetail().contains("constraint")); + + assertEquals("rsu_milepost_primary_route", body.getProperties().get("constraint")); + } + + @Test + void testRsuDuplicateSerialNumber() { + String errorMessage = "could not execute statement [ERROR: duplicate key value violates unique constraint \"rsu_milepost_primary_route\"" + + "Detail: Key (milepost, primary_route)=(1, I999) already exists.] " + + "[insert into rsus (credential_id,firmware_version,geography,ipv4_address,iss_scms_id,milepost,model,primary_route,serial_number,snmp_credential_id,snmp_protocol_id,target_firmware_version,rsu_id) values (?,?,?,?,?,?,?,?,?,?,?,?,?)]; " + + "SQL [insert into rsus (credential_id,firmware_version,geography,ipv4_address,iss_scms_id,milepost,model,primary_route,serial_number,snmp_credential_id,snmp_protocol_id,target_firmware_version,rsu_id) values (?,?,?,?,?,?,?,?,?,?,?,?,?)]; " + + "constraint [rsu_milepost_primary_route]"; + + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ProblemDetail body = response.getBody(); + assertEquals("RSU with milepost '1' and primary route 'I999' already exists.", body.getDetail()); + + assertEquals("rsu_milepost_primary_route", body.getProperties().get("constraint")); + } + + @Test + void testForeignKeyNotPresent() { + String errorMessage = "could not execute statement [ERROR: insert or update on table \"rsus\" " + + "violates foreign key constraint \"fk_credential\" " + + "Detail: Key (credential_id)=(999) is not present in table \"credentials\".] " + + "constraint [fk_credential]"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("referenced item does not exist")); + assertFalse(response.getBody().getDetail().contains("SQL")); + } + + @Test + void testNotNull() { + String errorMessage = "could not execute statement [ERROR: null value in column \"ipv4_address\" " + + "violates not-null constraint]"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("IPv4 address")); + assertTrue(response.getBody().getDetail().contains("required")); + assertFalse(response.getBody().getDetail().contains("null value")); + } + + @Test + void testGenericConstraint() { + String errorMessage = "Generic database constraint violation"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("database constraint was violated")); + } + } + + @Nested + class HandleHttpMessageNotReadableTests { + + private HttpMessageNotReadableException withCause(Throwable cause) { + return new HttpMessageNotReadableException( + "Request body parse failed", + cause, + new MockHttpInputMessage(new byte[0])); + } + + @Test + void testGenericMalformedJsonMessageWhenCauseUnknown() { + HttpMessageNotReadableException ex = withCause(new RuntimeException("unknown parse issue")); + + ErrorResponse response = handler.handleHttpMessageNotReadable(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Request body is invalid or malformed JSON.", response.getBody().getDetail()); + } + + @Test + void testJsonParseExceptionReturnsMalformedSyntaxMessage() { + JsonParseException parseException = new JsonParseException((com.fasterxml.jackson.core.JsonParser) null, + "Unexpected character"); + HttpMessageNotReadableException ex = withCause(parseException); + + ErrorResponse response = handler.handleHttpMessageNotReadable(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Malformed JSON syntax in request body.", response.getBody().getDetail()); + } + + @Test + void testDeepestCauseIsUsedWhenNested() { + JsonParseException root = new JsonParseException((com.fasterxml.jackson.core.JsonParser) null, + "Unexpected end-of-input"); + RuntimeException wrapped = new RuntimeException("wrapper", root); + HttpMessageNotReadableException ex = withCause(wrapped); + + ErrorResponse response = handler.handleHttpMessageNotReadable(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("Malformed JSON syntax in request body.", response.getBody().getDetail()); + } + + @Test + void testInvalidFormatExceptionHandledAsMismatchedInput() { + InvalidFormatException invalidFormat = InvalidFormatException.from( + null, + "Invalid value", + "not-a-boolean", + Boolean.class); + HttpMessageNotReadableException ex = withCause(invalidFormat); + + ErrorResponse response = handler.handleHttpMessageNotReadable(ex); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + // Current handler checks MismatchedInputException before InvalidFormatException + assertEquals("JSON structure does not match the expected request format.", response.getBody().getDetail()); + } + } + + @Nested + class HandleExceptionTests { + + @Test + void testGenericException() { + RuntimeException ex = new RuntimeException("Unexpected error"); + + ErrorResponse response = handler.handleException(ex); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertTrue(response.getBody().getDetail().contains("unexpected error occurred")); + assertFalse(response.getBody().getDetail().contains("RuntimeException")); + } + + @Test + void testNullPointerException() { + RuntimeException ex = new NullPointerException("Something was null"); + + ErrorResponse response = handler.handleException(ex); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + // Should return generic message, not expose internal details + assertFalse(response.getBody().getDetail().contains("null")); + } + } + + @Nested + class HelperMethodTests { + + @Test + void testBuildDuplicateKeyMessageExtractsFieldsAndValues() { + String message = "duplicate key value violates unique constraint \"rsu_serial_number\" " + + "Detail: Key (serial_number)=(E5673) already exists. " + + "[http-nio-8089-exec-9] WARN us.dot.its.jpo.ode.api.controllers.GlobalExceptionHandler - Data integrity violation: could not execute statement [ERROR: duplicate key value violates unique constraint \"rsu_serial_number\" " + + + "Detail: Key (serial_number)=(E5673) already exists.]"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(message); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + assertTrue(response.getBody().getDetail().contains("RSU")); + assertTrue(response.getBody().getDetail().contains("already exists")); + } + + @Test + void testFormatFieldNameHandlesSpecialCases() { + String message = "null value in column \"ipv4_address\" violates not-null constraint"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(message); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + // Should format ipv4_address as "IPv4 address" + assertTrue(response.getBody().getDetail().contains("IPv4 address")); + assertFalse(response.getBody().getDetail().contains("ipv4_address")); + } + + @Test + void testDetermineResourceTypeRecognizesRsu() { + String message = "duplicate key violates constraint \"rsus_pkey\""; + DataIntegrityViolationException ex = new DataIntegrityViolationException(message); + + ErrorResponse response = handler.handleDataIntegrityViolation(ex); + + // should not contain "RSU" since pattern doesn't match, but tests the + // method + assertNotNull(response.getBody().getDetail()); + } + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/RsuCredentialControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/RsuCredentialControllerTest.java new file mode 100644 index 000000000..e101c7626 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/RsuCredentialControllerTest.java @@ -0,0 +1,235 @@ +package us.dot.its.jpo.ode.api.controllers.credentials; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.ode.api.mappers.RsuCredentialMapper; +import us.dot.its.jpo.ode.api.mappers.RsuCredentialMapperImpl; +import us.dot.its.jpo.ode.api.models.credentials.RsuCredentialDTO; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuCredential; +import us.dot.its.jpo.ode.api.services.RsuCredentialManagementService; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RsuCredentialControllerTest { + + RsuCredentialManagementService mockRsuCredentialManagementService; + + RsuCredentialMapper rsuCredentialMapper; + + RsuCredentialController rsuCredentialController; + + @BeforeEach() + void setUp() { + mockRsuCredentialManagementService = mock(RsuCredentialManagementService.class); + rsuCredentialMapper = new RsuCredentialMapperImpl(); + } + + @Test + void testCreateRsuCredential_Success() throws RsuCredentialManagementService.RsuCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "testNickname"; + String username = "testUser"; + String password = "testPassword"; + String organization = "testOrg"; + int mockRsuCredentialId = 1; + int mockOrganizationId = 2; + RsuCredential mockRsuCredential = mock(); + when(mockRsuCredential.getId()).thenReturn(mockRsuCredentialId); + when(mockRsuCredential.getNickname()).thenReturn(nickname); + when(mockRsuCredential.getUsername()).thenReturn(username); + when(mockRsuCredential.getPassword()).thenReturn(password); + + Organization mockOrganization = mock(Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + when(mockRsuCredential.getOwnerOrganization()).thenReturn(mockOrganization); + + RsuCredentialController.RsuCredentialCreateRequest rsuCredentialCreateRequest = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + when(mockRsuCredentialManagementService.create(rsuCredentialCreateRequest)).thenReturn(mockRsuCredential); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + RsuCredentialDTO expected = new RsuCredentialDTO(mockRsuCredentialId, nickname, username, password, mockOrganizationId); + + // Act + RsuCredentialDTO response = rsuCredentialController.createRsuCredential(rsuCredentialCreateRequest); + + // Assert + assert(response != null); + assert(response.equals(expected)); + verify(mockRsuCredentialManagementService).create(rsuCredentialCreateRequest); + } + + @Test + void testCreateRsuCredential_Failure_AlreadyExists() throws EntityNotFoundException, RsuCredentialManagementService.RsuCredentialAlreadyExistsException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + RsuCredentialController.RsuCredentialCreateRequest rsuCredentialCreateRequest = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + when(mockRsuCredentialManagementService.create(rsuCredentialCreateRequest)).thenThrow(RsuCredentialManagementService.RsuCredentialAlreadyExistsException.class); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + // Act & Assert + assertThrows(RsuCredentialManagementService.RsuCredentialAlreadyExistsException.class, () -> rsuCredentialController.createRsuCredential(rsuCredentialCreateRequest)); + } + + @Test + void testCreateRsuCredential_Failure_OrganizationNotFound() throws EntityNotFoundException, RsuCredentialManagementService.RsuCredentialAlreadyExistsException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + RsuCredentialController.RsuCredentialCreateRequest rsuCredentialCreateRequest = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + doThrow(EntityNotFoundException.class).when(mockRsuCredentialManagementService).create(rsuCredentialCreateRequest); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialController.createRsuCredential(rsuCredentialCreateRequest)); + } + + @Test + void testGetByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "testNickname"; + String username = "testUser"; + String password = "testPassword"; + int mockRsuCredentialId = 1; + int mockOrganizationId = 2; + RsuCredential mockRsuCredential = mock(); + when(mockRsuCredential.getId()).thenReturn(mockRsuCredentialId); + when(mockRsuCredential.getNickname()).thenReturn(nickname); + when(mockRsuCredential.getUsername()).thenReturn(username); + when(mockRsuCredential.getPassword()).thenReturn(password); + + Organization mockOrganization = mock(Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + when(mockRsuCredential.getOwnerOrganization()).thenReturn(mockOrganization); + when(mockRsuCredentialManagementService.getByNickname(nickname)).thenReturn(mockRsuCredential); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + RsuCredentialController.RsuCredentialGetRequest rsuCredentialGetRequest = new RsuCredentialController.RsuCredentialGetRequest(nickname); + RsuCredentialDTO expected = new RsuCredentialDTO(mockRsuCredentialId, nickname, username, password, mockOrganizationId); + + // Act + RsuCredentialDTO actual = rsuCredentialController.getByNickname(rsuCredentialGetRequest); + + // Assert + assert(actual != null); + assert(actual.equals(expected)); + verify(mockRsuCredentialManagementService).getByNickname(nickname); + } + + @Test + void testGetByNickname_Failure_NotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + when(mockRsuCredentialManagementService.getByNickname(nickname)).thenThrow(EntityNotFoundException.class); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + RsuCredentialController.RsuCredentialGetRequest rsuCredentialGetRequest = new RsuCredentialController.RsuCredentialGetRequest(nickname); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialController.getByNickname(rsuCredentialGetRequest)); + } + + @Test + void testUpdate_Success() throws EntityNotFoundException { + // Arrange + String nickname = "testNickname"; + String username = "testUser"; + String updatedPassword = "updatedPassword"; + int mockRsuCredentialId = 1; + int mockOrganizationId = 2; + RsuCredential mockUpdatedRsuCredential = mock(); + when(mockUpdatedRsuCredential.getId()).thenReturn(mockRsuCredentialId); + when(mockUpdatedRsuCredential.getNickname()).thenReturn(nickname); + when(mockUpdatedRsuCredential.getUsername()).thenReturn(username); + + Organization mockOrganization = mock(Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + when(mockUpdatedRsuCredential.getOwnerOrganization()).thenReturn(mockOrganization); + + when(mockUpdatedRsuCredential.getPassword()).thenReturn(updatedPassword); + + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setPassword(updatedPassword); + + when(mockRsuCredentialManagementService.update(rsuCredentialPatch)).thenReturn(mockUpdatedRsuCredential); + + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + // Act + RsuCredentialDTO response = rsuCredentialController.update(rsuCredentialPatch); + + // Assert + assert(response != null); + assert(response.getId().equals(mockRsuCredentialId)); + verify(mockRsuCredentialManagementService).update(rsuCredentialPatch); + } + + @Test + void testUpdate_Failure_CredentialNotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setPassword(""); + when(mockRsuCredentialManagementService.update(rsuCredentialPatch)).thenThrow(EntityNotFoundException.class); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialController.update(rsuCredentialPatch)); + } + + @Test + void testUpdate_Failure_OrganizationNotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + when(mockRsuCredentialManagementService.update(rsuCredentialPatch)).thenThrow(EntityNotFoundException.class); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialController.update(rsuCredentialPatch)); + } + + @Test + void testDelete_Success() { + // Arrange + String nickname = "testNickname"; + + doNothing().when(mockRsuCredentialManagementService).deleteByNickname(nickname); + + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + + RsuCredentialController.RsuCredentialDeleteRequest deleteRequest = new RsuCredentialController.RsuCredentialDeleteRequest(nickname); + + // Act + rsuCredentialController.deleteByNickname(deleteRequest); + + // Assert + verify(mockRsuCredentialManagementService).deleteByNickname(nickname); + } + + @Test + void testDelete_Failure() { + // Arrange + String nickname = "nickname"; + doThrow(EntityNotFoundException.class).when(mockRsuCredentialManagementService).deleteByNickname(nickname); + rsuCredentialController = new RsuCredentialController(mockRsuCredentialManagementService, rsuCredentialMapper); + RsuCredentialController.RsuCredentialDeleteRequest deleteRequest = new RsuCredentialController.RsuCredentialDeleteRequest(nickname); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialController.deleteByNickname(deleteRequest)); + } + +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/SnmpCredentialControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/SnmpCredentialControllerTest.java new file mode 100644 index 000000000..39cd510c7 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/credentials/SnmpCredentialControllerTest.java @@ -0,0 +1,221 @@ +package us.dot.its.jpo.ode.api.controllers.credentials; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.ode.api.mappers.SnmpCredentialMapper; +import us.dot.its.jpo.ode.api.mappers.SnmpCredentialMapperImpl; +import us.dot.its.jpo.ode.api.models.credentials.SnmpCredentialDTO; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpCredential; +import us.dot.its.jpo.ode.api.services.SnmpCredentialManagementService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SnmpCredentialControllerTest { + + SnmpCredentialManagementService mockSnmpCredentialManagementService; + + SnmpCredentialMapper snmpCredentialMapper; + + SnmpCredentialController snmpCredentialController; + + @BeforeEach + void setUp() { + mockSnmpCredentialManagementService = mock(SnmpCredentialManagementService.class); + snmpCredentialMapper = new SnmpCredentialMapperImpl(); + } + + @Test + void testCreateSnmpCredential_Success() throws SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int mockOrganizationId = 1; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + + SnmpCredential snmpCredential = new SnmpCredential(); + snmpCredential.setNickname(nickname); + snmpCredential.setUsername(username); + snmpCredential.setPassword(password); + us.dot.its.jpo.ode.api.models.postgres.tables.Organization mockOrganization = mock(us.dot.its.jpo.ode.api.models.postgres.tables.Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + snmpCredential.setOwnerOrganization(mockOrganization); + + SnmpCredentialDTO expected = new SnmpCredentialDTO(null, nickname, username, password, mockOrganizationId); + + when(mockSnmpCredentialManagementService.create(request)).thenReturn(snmpCredential); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act + SnmpCredentialDTO response = snmpCredentialController.createSnmpCredential(request); + + // Assert + assertNotNull(response); + assertEquals(expected, response); + verify(mockSnmpCredentialManagementService).create(request); + } + + @Test + void testCreateSnmpCredential_Failure_AlreadyExists() throws SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + when(mockSnmpCredentialManagementService.create(request)).thenThrow(SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException.class); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act & Assert + assertThrows(SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException.class, () -> snmpCredentialController.createSnmpCredential(request)); + } + + @Test + void testCreateSnmpCredential_Failure_OrganizationNotFound() throws SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + when(mockSnmpCredentialManagementService.create(request)).thenThrow(EntityNotFoundException.class); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialController.createSnmpCredential(request)); + } + + @Test + void testGetByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + int mockRsuCredentialId = 1; + int mockOrganizationId = 1; + + SnmpCredentialController.SnmpCredentialGetRequest request = new SnmpCredentialController.SnmpCredentialGetRequest(nickname); + + SnmpCredential existingCredential = new SnmpCredential(); + existingCredential.setId(mockRsuCredentialId); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + us.dot.its.jpo.ode.api.models.postgres.tables.Organization mockOrganization = mock(us.dot.its.jpo.ode.api.models.postgres.tables.Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + existingCredential.setOwnerOrganization(mockOrganization); + + SnmpCredentialDTO expected = new SnmpCredentialDTO(mockRsuCredentialId, nickname, username, password, mockOrganizationId); + + when(mockSnmpCredentialManagementService.getByNickname(nickname)).thenReturn(existingCredential); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act + SnmpCredentialDTO actual = snmpCredentialController.getByNickname(request); + + // Assert + assertNotNull(actual); + assertEquals(expected, actual); + verify(mockSnmpCredentialManagementService).getByNickname(nickname); + } + + @Test + void testGetByNickname_Failure_NotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + SnmpCredentialController.SnmpCredentialGetRequest request = new SnmpCredentialController.SnmpCredentialGetRequest(nickname); + when(mockSnmpCredentialManagementService.getByNickname(nickname)).thenThrow(EntityNotFoundException.class); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialController.getByNickname(request)); + } + + @Test + void testUpdate_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String updatedPassword = "updatedPassword"; + int mockRsuCredentialId = 1; + int mockOrganizationId = 2; + SnmpCredential snmpCredential = new SnmpCredential(); + snmpCredential.setId(mockRsuCredentialId); + snmpCredential.setNickname(nickname); + snmpCredential.setUsername(username); + snmpCredential.setPassword(updatedPassword); + us.dot.its.jpo.ode.api.models.postgres.tables.Organization mockOrganization = mock(us.dot.its.jpo.ode.api.models.postgres.tables.Organization.class); + when(mockOrganization.getId()).thenReturn(mockOrganizationId); + snmpCredential.setOwnerOrganization(mockOrganization); + + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setPassword(updatedPassword); + + when(mockSnmpCredentialManagementService.update(patch)).thenReturn(snmpCredential); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + SnmpCredentialDTO expected = new SnmpCredentialDTO(mockRsuCredentialId, nickname, username, updatedPassword, mockOrganizationId); + + // Act + SnmpCredentialDTO response = snmpCredentialController.update(patch); + + // Assert + assertNotNull(response); + assertEquals(expected, response); + verify(mockSnmpCredentialManagementService).update(patch); + } + + @Test + void testUpdate_Failure_EntityNotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setPassword(""); + when(mockSnmpCredentialManagementService.update(patch)).thenThrow(EntityNotFoundException.class); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialController.update(patch)); + } + + @Test + void testDeleteByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + + SnmpCredentialController.SnmpCredentialDeleteRequest request = new SnmpCredentialController.SnmpCredentialDeleteRequest(nickname); + + doNothing().when(mockSnmpCredentialManagementService).deleteByNickname(nickname); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + + // Act + snmpCredentialController.deleteByNickname(request); + + // Assert + verify(mockSnmpCredentialManagementService).deleteByNickname(nickname); + } + + @Test + void testDelete_Failure() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + doThrow(EntityNotFoundException.class).when(mockSnmpCredentialManagementService).deleteByNickname(nickname); + snmpCredentialController = new SnmpCredentialController(mockSnmpCredentialManagementService, snmpCredentialMapper); + SnmpCredentialController.SnmpCredentialDeleteRequest request = new SnmpCredentialController.SnmpCredentialDeleteRequest(nickname); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialController.deleteByNickname(request)); + + } + +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/BsmControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/BsmControllerTest.java index c7b068394..97e5e66f7 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/BsmControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/BsmControllerTest.java @@ -11,7 +11,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -21,119 +20,119 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.accessors.bsm.OdeBsmJsonRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockBsmGenerator; import us.dot.its.jpo.ode.model.OdeMessageFrameData; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class BsmControllerTest { - private final BsmController controller; + private final BsmController controller; - @MockitoBean - OdeBsmJsonRepository odeBsmJsonRepo; + @MockitoBean + OdeBsmJsonRepository odeBsmJsonRepo; - @MockitoBean - PermissionService permissionService; + @MockitoBean + PermissionService permissionService; - @Autowired - public BsmControllerTest(BsmController controller) { - this.controller = controller; - } + @Autowired + public BsmControllerTest(BsmController controller) { + this.controller = controller; + } - @Test - public void testBsmJson() { + @Test + public void testBsmJson() { - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); - List list = new ArrayList<>(); + List list = new ArrayList<>(); - PageRequest page = PageRequest.of(0, 1); - when(odeBsmJsonRepo.find(null, null, null, null, null, null, null, - PageRequest.of(0, 1))) - .thenReturn(new PageImpl<>(list, page, 1L)); + PageRequest page = PageRequest.of(0, 1); + when(odeBsmJsonRepo.find(null, null, null, null, null, null, null, + PageRequest.of(0, 1))) + .thenReturn(new PageImpl<>(list, page, 1L)); - ResponseEntity> result = controller.findBSMs(null, null, null, - null, null, null, null, - 0, 1, - false); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody().getContent()).isEqualTo(list); - } + ResponseEntity> result = controller.findBSMs(null, null, null, + null, null, null, null, + 0, 1, + false); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().getContent()).isEqualTo(list); + } - @Test - void testFindOdeBsmWithTestData() { - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; + @Test + void testFindOdeBsmWithTestData() { + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; - ResponseEntity> response = controller - .findBSMs(null, null, null, null, null, null, null, - 0, 10, - testData); + ResponseEntity> response = controller + .findBSMs(null, null, null, null, null, null, null, + 0, 10, + testData); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } + assertFalse(response.getBody().getContent().isEmpty()); + } - @Test - void testFindOdeBsmsWithPagination() { - List events = MockBsmGenerator.getJsonBsms(); + @Test + void testFindOdeBsmsWithPagination() { + List events = MockBsmGenerator.getJsonBsms(); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(odeBsmJsonRepo.find(any(), any(), any(), any(), any(), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(odeBsmJsonRepo.find(any(), any(), any(), any(), any(), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); - ResponseEntity> response = controller - .findBSMs(null, null, null, null, null, null, null, - 0, 10, - false); + ResponseEntity> response = controller + .findBSMs(null, null, null, null, null, null, null, + 0, 10, + false); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(odeBsmJsonRepo, times(1)) - .find(any(), any(), any(), any(), any(), any(), any(), any(PageRequest.class)); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(odeBsmJsonRepo, times(1)) + .find(any(), any(), any(), any(), any(), any(), any(), any(PageRequest.class)); + } - @Test - public void testCountOdeBsmsWithTestData() { - boolean testData = true; + @Test + public void testCountOdeBsmsWithTestData() { + boolean testData = true; - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); - ResponseEntity response = controller.countBSMs(null, null, null, null, null, - null, null, testData); + ResponseEntity response = controller.countBSMs(null, null, null, null, null, + null, null, testData); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(10L); // Test data should return 10 items - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(10L); // Test data should return 10 items + } - @Test - public void testCountOdeBsms() { - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; + @Test + public void testCountOdeBsms() { + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; - when(permissionService.hasIntersection(null, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(odeBsmJsonRepo.count(null, null, startTime, endTime, null, null, null)) - .thenReturn(expectedCount); + when(permissionService.hasIntersection(null, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(odeBsmJsonRepo.count(null, null, startTime, endTime, null, null, null)) + .thenReturn(expectedCount); - ResponseEntity response = controller.countBSMs(null, null, - startTime, endTime, null, null, null, false); + ResponseEntity response = controller.countBSMs(null, null, + startTime, endTime, null, null, null, false); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(odeBsmJsonRepo, times(1)).count(null, null, startTime, endTime, null, null, null); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(odeBsmJsonRepo, times(1)).count(null, null, startTime, endTime, null, null, null); + } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmAssessmentControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmAssessmentControllerTest.java index 5d601ea60..003798875 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmAssessmentControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmAssessmentControllerTest.java @@ -12,7 +12,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -23,9 +22,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.ConnectionOfTravelAssessment; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.LaneDirectionOfTravelAssessment; import us.dot.its.jpo.conflictmonitor.monitor.models.assessments.StopLinePassageAssessment; @@ -34,466 +33,466 @@ import us.dot.its.jpo.ode.api.accessors.assessments.lane_direction_of_travel_assessment.LaneDirectionOfTravelAssessmentRepository; import us.dot.its.jpo.ode.api.accessors.assessments.stop_line_passage_assessment.StopLinePassageAssessmentRepository; import us.dot.its.jpo.ode.api.accessors.assessments.stop_line_stop_assessment.StopLineStopAssessmentRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockAssessmentGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class CmAssessmentControllerTest { - private final CmAssessmentController controller; + private final CmAssessmentController controller; - @MockitoBean - LaneDirectionOfTravelAssessmentRepository laneDirectionOfTravelAssessmentRepo; + @MockitoBean + LaneDirectionOfTravelAssessmentRepository laneDirectionOfTravelAssessmentRepo; - @MockitoBean - ConnectionOfTravelAssessmentRepository connectionOfTravelAssessmentRepo; + @MockitoBean + ConnectionOfTravelAssessmentRepository connectionOfTravelAssessmentRepo; - @MockitoBean - StopLineStopAssessmentRepository stopLineStopAssessmentRepo; + @MockitoBean + StopLineStopAssessmentRepository stopLineStopAssessmentRepo; - @MockitoBean - StopLinePassageAssessmentRepository stopLinePassageAssessmentRepo; + @MockitoBean + StopLinePassageAssessmentRepository stopLinePassageAssessmentRepo; - @MockitoBean - PermissionService permissionService; + @MockitoBean + PermissionService permissionService; - @Autowired - public CmAssessmentControllerTest(CmAssessmentController controller) { - this.controller = controller; - } + @Autowired + public CmAssessmentControllerTest(CmAssessmentController controller) { + this.controller = controller; + } - @Test - void testFindConnectionOfTravelAssessmentWithTestData() { - ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); + @Test + void testFindConnectionOfTravelAssessmentWithTestData() { + ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); - List events = new ArrayList<>(); - events.add(event); + List events = new ArrayList<>(); + events.add(event); - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; - ResponseEntity> response = controller - .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, false, - 0, 10, - testData); + ResponseEntity> response = controller + .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, false, + 0, 10, + testData); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } - @Test - void testFindConnectionOfTravelAssessmentsWithLatestFlag() { - ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); + @Test + void testFindConnectionOfTravelAssessmentsWithLatestFlag() { + ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); - List events = new ArrayList<>(); - events.add(event); + List events = new ArrayList<>(); + events.add(event); - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; - Page mockPage = new PageImpl<>(events); - when(connectionOfTravelAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); + Page mockPage = new PageImpl<>(events); + when(connectionOfTravelAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); - ResponseEntity> response = controller - .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); + ResponseEntity> response = controller + .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(connectionOfTravelAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(connectionOfTravelAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } - @Test - void testFindConnectionOfTravelAssessmentsWithPagination() { - ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); + @Test + void testFindConnectionOfTravelAssessmentsWithPagination() { + ConnectionOfTravelAssessment event = MockAssessmentGenerator.getConnectionOfTravelAssessment(); - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(connectionOfTravelAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(connectionOfTravelAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); - ResponseEntity> response = controller - .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); + ResponseEntity> response = controller + .findConnectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(connectionOfTravelAssessmentRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountConnectionOfTravelAssessmentsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countConnectionOfTravelAssessments(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountConnectionOfTravelAssessments() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(connectionOfTravelAssessmentRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countConnectionOfTravelAssessments(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(connectionOfTravelAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindLaneDirectionOfTravelAssessmentWithTestData() { - LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindLaneDirectionOfTravelAssessmentsWithLatestFlag() { - LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(laneDirectionOfTravelAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(laneDirectionOfTravelAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindLaneDirectionOfTravelAssessmentsWithPagination() { - LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(laneDirectionOfTravelAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(laneDirectionOfTravelAssessmentRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountLaneDirectionOfTravelAssessmentsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countLaneDirectionOfTravelAssessments(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountLaneDirectionOfTravelAssessments() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(laneDirectionOfTravelAssessmentRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countLaneDirectionOfTravelAssessments(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(laneDirectionOfTravelAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindStopLineStopAssessmentWithTestData() { - StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findStopLineStopAssessments(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindStopLineStopAssessmentsWithLatestFlag() { - StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(stopLineStopAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLineStopAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLineStopAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindStopLineStopAssessmentsWithPagination() { - StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(stopLineStopAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLineStopAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLineStopAssessmentRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountStopLineStopAssessmentsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countStopLineStopAssessments(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountStopLineStopAssessments() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLineStopAssessmentRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countStopLineStopAssessments(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(stopLineStopAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindStopLinePassageAssessmentWithTestData() { - StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findStopLinePassageAssessments(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindStopLinePassageAssessmentsWithLatestFlag() { - StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(stopLinePassageAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLinePassageAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLinePassageAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindStopLinePassageAssessmentsWithPagination() { - StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(stopLinePassageAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLinePassageAssessments(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLinePassageAssessmentRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountStopLinePassageAssessmentsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countStopLinePassageAssessments(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountStopLinePassageAssessments() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLinePassageAssessmentRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countStopLinePassageAssessments(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(stopLinePassageAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(connectionOfTravelAssessmentRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountConnectionOfTravelAssessmentsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countConnectionOfTravelAssessments(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountConnectionOfTravelAssessments() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(connectionOfTravelAssessmentRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countConnectionOfTravelAssessments(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(connectionOfTravelAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindLaneDirectionOfTravelAssessmentWithTestData() { + LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindLaneDirectionOfTravelAssessmentsWithLatestFlag() { + LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(laneDirectionOfTravelAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(laneDirectionOfTravelAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindLaneDirectionOfTravelAssessmentsWithPagination() { + LaneDirectionOfTravelAssessment event = MockAssessmentGenerator.getLaneDirectionOfTravelAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(laneDirectionOfTravelAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findLaneDirectionOfTravelAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(laneDirectionOfTravelAssessmentRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountLaneDirectionOfTravelAssessmentsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countLaneDirectionOfTravelAssessments(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountLaneDirectionOfTravelAssessments() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(laneDirectionOfTravelAssessmentRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countLaneDirectionOfTravelAssessments(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(laneDirectionOfTravelAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindStopLineStopAssessmentWithTestData() { + StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findStopLineStopAssessments(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindStopLineStopAssessmentsWithLatestFlag() { + StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(stopLineStopAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLineStopAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLineStopAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindStopLineStopAssessmentsWithPagination() { + StopLineStopAssessment event = MockAssessmentGenerator.getStopLineStopAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(stopLineStopAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLineStopAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLineStopAssessmentRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountStopLineStopAssessmentsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countStopLineStopAssessments(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountStopLineStopAssessments() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLineStopAssessmentRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countStopLineStopAssessments(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(stopLineStopAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindStopLinePassageAssessmentWithTestData() { + StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findStopLinePassageAssessments(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindStopLinePassageAssessmentsWithLatestFlag() { + StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(stopLinePassageAssessmentRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLinePassageAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLinePassageAssessmentRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindStopLinePassageAssessmentsWithPagination() { + StopLinePassageAssessment event = MockAssessmentGenerator.getStopLinePassageAssessment(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(stopLinePassageAssessmentRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLinePassageAssessments(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLinePassageAssessmentRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountStopLinePassageAssessmentsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countStopLinePassageAssessments(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountStopLinePassageAssessments() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLinePassageAssessmentRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countStopLinePassageAssessments(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(stopLinePassageAssessmentRepo, times(1)).count(intersectionID, startTime, endTime); + } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmEventControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmEventControllerTest.java index d46438cd7..39967182a 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmEventControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmEventControllerTest.java @@ -14,7 +14,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -24,9 +23,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.bsm.BsmEvent; import us.dot.its.jpo.conflictmonitor.monitor.models.events.BsmMessageCountProgressionEvent; import us.dot.its.jpo.conflictmonitor.monitor.models.events.ConnectionOfTravelEvent; @@ -61,2178 +60,2174 @@ import us.dot.its.jpo.ode.api.accessors.events.time_change_details_event.TimeChangeDetailsEventRepository; import us.dot.its.jpo.ode.api.models.IDCount; import us.dot.its.jpo.ode.api.models.MinuteCount; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; -import us.dot.its.jpo.ode.api.services.PostgresService; import us.dot.its.jpo.ode.mockdata.MockBsmGenerator; import us.dot.its.jpo.ode.mockdata.MockEventGenerator; import us.dot.its.jpo.ode.plugin.j2735.J2735Bsm; import us.dot.its.jpo.ode.plugin.j2735.J2735BsmCoreData; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class CmEventControllerTest { - private final CmEventController controller; + private final CmEventController controller; - @MockitoBean - ConnectionOfTravelEventRepository connectionOfTravelEventRepo; + @MockitoBean + ConnectionOfTravelEventRepository connectionOfTravelEventRepo; - @MockitoBean - IntersectionReferenceAlignmentEventRepository intersectionReferenceAlignmentEventRepo; + @MockitoBean + IntersectionReferenceAlignmentEventRepository intersectionReferenceAlignmentEventRepo; - @MockitoBean - LaneDirectionOfTravelEventRepository laneDirectionOfTravelRepo; + @MockitoBean + LaneDirectionOfTravelEventRepository laneDirectionOfTravelRepo; - @MockitoBean - SignalGroupAlignmentEventRepository signalGroupAlignmentEventRepo; + @MockitoBean + SignalGroupAlignmentEventRepository signalGroupAlignmentEventRepo; - @MockitoBean - SignalStateConflictEventRepository signalStateConflictEventRepo; + @MockitoBean + SignalStateConflictEventRepository signalStateConflictEventRepo; - @MockitoBean - StopLineStopEventRepository stopLineStopEventRepo; + @MockitoBean + StopLineStopEventRepository stopLineStopEventRepo; - @MockitoBean - StopLinePassageEventRepository stopLinePassageEventRepo; + @MockitoBean + StopLinePassageEventRepository stopLinePassageEventRepo; - @MockitoBean - TimeChangeDetailsEventRepository timeChangeDetailsEventRepo; + @MockitoBean + TimeChangeDetailsEventRepository timeChangeDetailsEventRepo; - @MockitoBean - SpatMinimumDataEventRepository spatMinimumDataEventRepo; + @MockitoBean + SpatMinimumDataEventRepository spatMinimumDataEventRepo; - @MockitoBean - MapMinimumDataEventRepository mapMinimumDataEventRepo; + @MockitoBean + MapMinimumDataEventRepository mapMinimumDataEventRepo; - @MockitoBean - SpatBroadcastRateEventRepository spatBroadcastRateEventRepo; + @MockitoBean + SpatBroadcastRateEventRepository spatBroadcastRateEventRepo; - @MockitoBean - MapBroadcastRateEventRepository mapBroadcastRateEventRepo; + @MockitoBean + MapBroadcastRateEventRepository mapBroadcastRateEventRepo; - @MockitoBean - SpatMessageCountProgressionEventRepository spatMessageCountProgressionEventRepo; + @MockitoBean + SpatMessageCountProgressionEventRepository spatMessageCountProgressionEventRepo; - @MockitoBean - MapMessageCountProgressionEventRepository mapMessageCountProgressionEventRepo; + @MockitoBean + MapMessageCountProgressionEventRepository mapMessageCountProgressionEventRepo; - @MockitoBean - BsmMessageCountProgressionEventRepository bsmMessageCountProgressionEventRepo; + @MockitoBean + BsmMessageCountProgressionEventRepository bsmMessageCountProgressionEventRepo; - @MockitoBean - BsmEventRepository bsmEventRepo; + @MockitoBean + BsmEventRepository bsmEventRepo; - @MockitoBean - PostgresService postgresService; + @MockitoBean + PermissionService permissionService; - @MockitoBean - PermissionService permissionService; + @Autowired + public CmEventControllerTest(CmEventController controller) { + this.controller = controller; + } - @Autowired - public CmEventControllerTest(CmEventController controller) { - this.controller = controller; - } + @Test + public void testIntersectionReferenceAlignmentEvents() { - @Test - public void testIntersectionReferenceAlignmentEvents() { + IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); - IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); + List events = new ArrayList<>(); + events.add(event); - List events = new ArrayList<>(); - events.add(event); + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + PageRequest page = PageRequest.of(1, 1); + when(intersectionReferenceAlignmentEventRepo.find(event.getIntersectionID(), + event.getEventGeneratedAt() - 1, + event.getEventGeneratedAt() + 1, PageRequest.of(1, 1))) + .thenReturn(new PageImpl<>(events, page, 1L)); - PageRequest page = PageRequest.of(1, 1); - when(intersectionReferenceAlignmentEventRepo.find(event.getIntersectionID(), - event.getEventGeneratedAt() - 1, - event.getEventGeneratedAt() + 1, PageRequest.of(1, 1))) - .thenReturn(new PageImpl<>(events, page, 1L)); + ResponseEntity> result = controller + .findIntersectionReferenceAlignmentEvents( + event.getIntersectionID(), + event.getEventGeneratedAt() - 1, + event.getEventGeneratedAt() + 1, false, 1, 1, false); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().getContent()).isEqualTo(events); + } - ResponseEntity> result = controller - .findIntersectionReferenceAlignmentEvents( - event.getIntersectionID(), - event.getEventGeneratedAt() - 1, - event.getEventGeneratedAt() + 1, false, 1, 1, false); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody().getContent()).isEqualTo(events); - } + @Test + void testFindIntersectionReferenceAlignmentEventsWithTestData() { + IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); - @Test - void testFindIntersectionReferenceAlignmentEventsWithTestData() { - IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); + List events = new ArrayList<>(); + events.add(event); - List events = new ArrayList<>(); - events.add(event); + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; + ResponseEntity> response = controller + .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); - ResponseEntity> response = controller - .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } + @Test + void testFindIntersectionReferenceAlignmentEventsWithLatestFlag() { + IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); - @Test - void testFindIntersectionReferenceAlignmentEventsWithLatestFlag() { - IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); + List events = new ArrayList<>(); + events.add(event); - List events = new ArrayList<>(); - events.add(event); + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; + Page mockPage = new PageImpl<>(events); + when(intersectionReferenceAlignmentEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); - Page mockPage = new PageImpl<>(events); - when(intersectionReferenceAlignmentEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); + ResponseEntity> response = controller + .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); - ResponseEntity> response = controller - .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(intersectionReferenceAlignmentEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindIntersectionReferenceAlignmentEventsWithPagination() { - IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(intersectionReferenceAlignmentEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(intersectionReferenceAlignmentEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountIntersectionReferenceAlignmentEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countIntersectionReferenceAlignmentEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountIntersectionReferenceAlignmentEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(intersectionReferenceAlignmentEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countIntersectionReferenceAlignmentEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(intersectionReferenceAlignmentEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindConnectionOfTravelEventWithTestData() { - ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindConnectionOfTravelEventsWithLatestFlag() { - ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(connectionOfTravelEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(connectionOfTravelEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindConnectionOfTravelEventsWithPagination() { - ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(connectionOfTravelEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(connectionOfTravelEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountConnectionOfTravelEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countConnectionOfTravelEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountConnectionOfTravelEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(connectionOfTravelEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countConnectionOfTravelEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(connectionOfTravelEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailyConnectionOfTravelEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailyConnectionOfTravelEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailyConnectionOfTravelEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(connectionOfTravelEventRepo.getAggregatedDailyConnectionOfTravelEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailyConnectionOfTravelEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(connectionOfTravelEventRepo, times(1)) - .getAggregatedDailyConnectionOfTravelEventCounts(intersectionID, startTime, endTime); - } - - @Test - void testFindLaneDirectionOfTravelEventWithTestData() { - LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindLaneDirectionOfTravelEventsWithLatestFlag() { - LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(laneDirectionOfTravelRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(laneDirectionOfTravelRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindLaneDirectionOfTravelEventsWithPagination() { - LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(laneDirectionOfTravelRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(laneDirectionOfTravelRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountLaneDirectionOfTravelEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countLaneDirectionOfTravelEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountLaneDirectionOfTravelEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(laneDirectionOfTravelRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countLaneDirectionOfTravelEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(laneDirectionOfTravelRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailyLaneDirectionOfTravelEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailyLaneDirectionOfTravelEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailyLaneDirectionOfTravelEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(laneDirectionOfTravelRepo.getAggregatedDailyLaneDirectionOfTravelEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailyLaneDirectionOfTravelEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(laneDirectionOfTravelRepo, times(1)) - .getAggregatedDailyLaneDirectionOfTravelEventCounts(intersectionID, startTime, endTime); - } - - @Test - void testFindSignalGroupAlignmentEventWithTestData() { - SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindSignalGroupAlignmentEventsWithLatestFlag() { - SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(signalGroupAlignmentEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(signalGroupAlignmentEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindSignalGroupAlignmentEventsWithPagination() { - SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(signalGroupAlignmentEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(signalGroupAlignmentEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountSignalGroupAlignmentEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countSignalGroupAlignmentEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountSignalGroupAlignmentEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(signalGroupAlignmentEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countSignalGroupAlignmentEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(signalGroupAlignmentEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailySignalGroupAlignmentEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailySignalGroupAlignmentEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailySignalGroupAlignmentEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(signalGroupAlignmentEventRepo.getAggregatedDailySignalGroupAlignmentEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailySignalGroupAlignmentEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(signalGroupAlignmentEventRepo, times(1)) - .getAggregatedDailySignalGroupAlignmentEventCounts(intersectionID, startTime, endTime); - } - - @Test - void testFindSignalStateConflictEventWithTestData() { - SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findSignalStateConflictEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindSignalStateConflictEventsWithLatestFlag() { - SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(signalStateConflictEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSignalStateConflictEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(signalStateConflictEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindSignalStateConflictEventsWithPagination() { - SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(signalStateConflictEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSignalStateConflictEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(signalStateConflictEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountSignalStateConflictEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countSignalStateConflictEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountSignalStateConflictEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(signalStateConflictEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countSignalStateConflictEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(signalStateConflictEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailySignalStateConflictEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailySignalStateConflictEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailySignalStateConflictEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(signalStateConflictEventRepo.getAggregatedDailySignalStateConflictEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailySignalStateConflictEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(signalStateConflictEventRepo, times(1)) - .getAggregatedDailySignalStateConflictEventCounts(intersectionID, startTime, endTime); - } - - @Test - void testFindStopLinePassageEventWithTestData() { - StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findStopLinePassageEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindStopLinePassageEventsWithLatestFlag() { - StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(stopLinePassageEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLinePassageEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLinePassageEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindStopLinePassageEventsWithPagination() { - StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(stopLinePassageEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLinePassageEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLinePassageEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountStopLinePassageEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countStopLinePassageEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountStopLinePassageEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLinePassageEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countStopLinePassageEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(stopLinePassageEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailyStopLinePassageEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailyStopLinePassageEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailyStopLinePassageEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLinePassageEventRepo.getAggregatedDailyStopLinePassageEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailyStopLinePassageEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(stopLinePassageEventRepo, times(1)).getAggregatedDailyStopLinePassageEventCounts(intersectionID, - startTime, endTime); - } - - @Test - void testFindStopLineStopEventWithTestData() { - StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findStopLineStopEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindStopLineStopEventsWithLatestFlag() { - StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(stopLineStopEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLineStopEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLineStopEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindStopLineStopEventsWithPagination() { - StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(stopLineStopEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findStopLineStopEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(stopLineStopEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountStopLineStopEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countStopLineStopEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountStopLineStopEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLineStopEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countStopLineStopEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(stopLineStopEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailyStopLineStopEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailyStopLineStopEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailyStopLineStopEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(stopLineStopEventRepo.getAggregatedDailyStopLineStopEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailyStopLineStopEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(stopLineStopEventRepo, times(1)).getAggregatedDailyStopLineStopEventCounts(intersectionID, - startTime, endTime); - } - - @Test - void testFindTimeChangeDetailsEventWithTestData() { - TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindTimeChangeDetailsEventsWithLatestFlag() { - TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(timeChangeDetailsEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(timeChangeDetailsEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindTimeChangeDetailsEventsWithPagination() { - TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(timeChangeDetailsEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(timeChangeDetailsEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountTimeChangeDetailsEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countTimeChangeDetailsEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountTimeChangeDetailsEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(timeChangeDetailsEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countTimeChangeDetailsEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(timeChangeDetailsEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - public void testGetDailyTimeChangeDetailsEventCountsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getDailyTimeChangeDetailsEventCounts( - intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L - } - - @Test - public void testGetDailyTimeChangeDetailsEventCounts() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - List expectedCounts = List.of(new IDCount("1", 5.0)); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(timeChangeDetailsEventRepo.getAggregatedDailyTimeChangeDetailsEventCounts(intersectionID, - startTime, endTime)) - .thenReturn(expectedCounts); - - ResponseEntity> response = controller.getDailyTimeChangeDetailsEventCounts( - intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCounts); - verify(timeChangeDetailsEventRepo, times(1)) - .getAggregatedDailyTimeChangeDetailsEventCounts(intersectionID, startTime, endTime); - } - - @Test - void testFindSpatMinimumDataEventWithTestData() { - SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertTrue(response.getBody().getContent().isEmpty()); // Test data should return empty - } - - @Test - void testFindSpatMinimumDataEventsWithLatestFlag() { - SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(spatMinimumDataEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatMinimumDataEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindSpatMinimumDataEventsWithPagination() { - SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(spatMinimumDataEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatMinimumDataEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountSpatMinimumDataEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countSpatMinimumDataEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountSpatMinimumDataEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(spatMinimumDataEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countSpatMinimumDataEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(spatMinimumDataEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindMapMinimumDataEventWithTestData() { - MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findMapMinimumDataEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertTrue(response.getBody().getContent().isEmpty()); // Test data should return empty - } - - @Test - void testFindMapMinimumDataEventsWithLatestFlag() { - MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(mapMinimumDataEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapMinimumDataEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapMinimumDataEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindMapMinimumDataEventsWithPagination() { - MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(mapMinimumDataEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapMinimumDataEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapMinimumDataEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountMapMinimumDataEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countMapMinimumDataEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountMapMinimumDataEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(mapMinimumDataEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countMapMinimumDataEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(mapMinimumDataEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindMapBroadcastRateEventWithTestData() { - MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindMapBroadcastRateEventsWithLatestFlag() { - MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(mapBroadcastRateEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapBroadcastRateEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindMapBroadcastRateEventsWithPagination() { - MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(mapBroadcastRateEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapBroadcastRateEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountMapBroadcastRateEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countMapBroadcastRateEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountMapBroadcastRateEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(mapBroadcastRateEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countMapBroadcastRateEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(mapBroadcastRateEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindSpatBroadcastRateEventWithTestData() { - SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindSpatBroadcastRateEventsWithLatestFlag() { - SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(spatBroadcastRateEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatBroadcastRateEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindSpatBroadcastRateEventsWithPagination() { - SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(spatBroadcastRateEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatBroadcastRateEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountSpatBroadcastRateEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countSpatBroadcastRateEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountSpatBroadcastRateEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(spatBroadcastRateEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countSpatBroadcastRateEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(spatBroadcastRateEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindSpatMessageCountProgressionEventWithTestData() { - SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindSpatMessageCountProgressionEventsWithLatestFlag() { - SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(spatMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindSpatMessageCountProgressionEventsWithPagination() { - SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(spatMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(spatMessageCountProgressionEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountSpatMessageCountProgressionEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countSpatMessageCountProgressionEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountSpatMessageCountProgressionEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(spatMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countSpatMessageCountProgressionEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(spatMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - void testFindMapMessageCountProgressionEventWithTestData() { - MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindMapMessageCountProgressionEventsWithLatestFlag() { - MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(mapMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindMapMessageCountProgressionEventsWithPagination() { - MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(mapMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(mapMessageCountProgressionEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountMapMessageCountProgressionEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countMapMessageCountProgressionEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountMapMessageCountProgressionEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(mapMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countMapMessageCountProgressionEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(mapMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindBsmMessageCountProgressionEventWithTestData() { - BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindBsmMessageCountProgressionEventsWithLatestFlag() { - BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(bsmMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(bsmMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindBsmMessageCountProgressionEventsWithPagination() { - BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(bsmMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(bsmMessageCountProgressionEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountBsmMessageCountProgressionEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countBsmMessageCountProgressionEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountBsmMessageCountProgressionEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(bsmMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countBsmMessageCountProgressionEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(bsmMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testFindBsmEventWithTestData() { - BsmEvent event = MockEventGenerator.getBsmEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean testData = true; - - ResponseEntity> response = controller - .findBsmEvents(event.getIntersectionID(), null, null, false, - 0, 10, - testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertFalse(response.getBody().getContent().isEmpty()); - } - - @Test - void testFindBsmEventsWithLatestFlag() { - BsmEvent event = MockEventGenerator.getBsmEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = true; - - Page mockPage = new PageImpl<>(events); - when(bsmEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findBsmEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(bsmEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), - any(), any()); - } - - @Test - void testFindBsmEventsWithPagination() { - BsmEvent event = MockEventGenerator.getBsmEvent(); - - List events = new ArrayList<>(); - events.add(event); - - when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - boolean latest = false; - - Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); - when(bsmEventRepo.find(eq(event.getIntersectionID()), any(), any(), - any(PageRequest.class))) - .thenReturn(mockPage); - - ResponseEntity> response = controller - .findBsmEvents(event.getIntersectionID(), null, null, latest, - 0, 10, - false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEqualTo(events); - verify(bsmEventRepo, times(1)) - .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); - } - - @Test - public void testCountBsmEventsWithTestData() { - Integer intersectionID = 1; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity response = controller.countBsmEvents(intersectionID, - null, null, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L - } - - @Test - public void testCountBsmEvents() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - Long expectedCount = 5L; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - when(bsmEventRepo.count(intersectionID, startTime, endTime)) - .thenReturn(expectedCount); - - ResponseEntity response = controller.countBsmEvents(intersectionID, - startTime, endTime, false); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isEqualTo(expectedCount); - verify(bsmEventRepo, times(1)).count(intersectionID, startTime, endTime); - } - - @Test - void testGetBsmActivityByMinuteInRangeWithTestData() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - boolean testData = true; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - ResponseEntity> response = controller.getBsmActivityByMinuteInRange( - intersectionID, startTime, endTime, false, 0, 10, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isNotEmpty(); - assertThat(response.getBody().getContent().size()).isEqualTo(10); // Test data generates 10 items - } - - @Test - void testGetBsmActivityByMinuteInRangeWithLatestFlag() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - boolean latest = true; - boolean testData = false; - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - Page mockPage = new PageImpl<>(Collections.emptyList()); - when(bsmEventRepo.findLatest(intersectionID, startTime, endTime)).thenReturn(mockPage); - - ResponseEntity> response = controller.getBsmActivityByMinuteInRange( - intersectionID, startTime, endTime, latest, 0, 10, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isEmpty(); // No events in the mock repository - verify(bsmEventRepo, times(1)).findLatest(intersectionID, startTime, endTime); - } - - @Test - void testGetBsmActivityByMinuteInRangeWithPagination() { - Integer intersectionID = 1; - Long startTime = 1000L; - Long endTime = 2000L; - boolean latest = false; - boolean testData = false; - J2735Bsm bsm = new J2735Bsm(); - J2735BsmCoreData coreData = new J2735BsmCoreData(); - coreData.setId("id"); - bsm.setCoreData(coreData); - - when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - BsmEvent mockEvent = new BsmEvent(); - // mockEvent.setStartingBsm(new OdeBsmData(new OdeMsgMetadata(), new - // OdeMsgPayload(bsm))); - // mockEvent.setEndingBsm(new OdeBsmData(new OdeMsgMetadata(), new - // OdeMsgPayload())); - - mockEvent.setStartingBsm(MockBsmGenerator.getProcessedBsms().getFirst()); - mockEvent.setEndingBsm(MockBsmGenerator.getProcessedBsms().getFirst()); - - Page mockPage = new PageImpl<>(Collections.singletonList(mockEvent), PageRequest.of(0, 10), - 1); - when(bsmEventRepo.find(intersectionID, startTime, endTime, PageRequest.of(0, 10))).thenReturn(mockPage); - - ResponseEntity> response = controller.getBsmActivityByMinuteInRange( - intersectionID, startTime, endTime, latest, 0, 10, testData); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody().getContent()).isNotEmpty(); - verify(bsmEventRepo, times(1)).find(intersectionID, startTime, endTime, PageRequest.of(0, 10)); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(intersectionReferenceAlignmentEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindIntersectionReferenceAlignmentEventsWithPagination() { + IntersectionReferenceAlignmentEvent event = MockEventGenerator.getIntersectionReferenceAlignmentEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(intersectionReferenceAlignmentEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findIntersectionReferenceAlignmentEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(intersectionReferenceAlignmentEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountIntersectionReferenceAlignmentEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countIntersectionReferenceAlignmentEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountIntersectionReferenceAlignmentEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(intersectionReferenceAlignmentEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countIntersectionReferenceAlignmentEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(intersectionReferenceAlignmentEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindConnectionOfTravelEventWithTestData() { + ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindConnectionOfTravelEventsWithLatestFlag() { + ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(connectionOfTravelEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(connectionOfTravelEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindConnectionOfTravelEventsWithPagination() { + ConnectionOfTravelEvent event = MockEventGenerator.getConnectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(connectionOfTravelEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findConnectionOfTravelEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(connectionOfTravelEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountConnectionOfTravelEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countConnectionOfTravelEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountConnectionOfTravelEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(connectionOfTravelEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countConnectionOfTravelEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(connectionOfTravelEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailyConnectionOfTravelEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailyConnectionOfTravelEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailyConnectionOfTravelEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(connectionOfTravelEventRepo.getAggregatedDailyConnectionOfTravelEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailyConnectionOfTravelEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(connectionOfTravelEventRepo, times(1)) + .getAggregatedDailyConnectionOfTravelEventCounts(intersectionID, startTime, endTime); + } + + @Test + void testFindLaneDirectionOfTravelEventWithTestData() { + LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindLaneDirectionOfTravelEventsWithLatestFlag() { + LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(laneDirectionOfTravelRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(laneDirectionOfTravelRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindLaneDirectionOfTravelEventsWithPagination() { + LaneDirectionOfTravelEvent event = MockEventGenerator.getLaneDirectionOfTravelEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(laneDirectionOfTravelRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findLaneDirectionOfTravelEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(laneDirectionOfTravelRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountLaneDirectionOfTravelEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countLaneDirectionOfTravelEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountLaneDirectionOfTravelEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(laneDirectionOfTravelRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countLaneDirectionOfTravelEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(laneDirectionOfTravelRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailyLaneDirectionOfTravelEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailyLaneDirectionOfTravelEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailyLaneDirectionOfTravelEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(laneDirectionOfTravelRepo.getAggregatedDailyLaneDirectionOfTravelEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailyLaneDirectionOfTravelEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(laneDirectionOfTravelRepo, times(1)) + .getAggregatedDailyLaneDirectionOfTravelEventCounts(intersectionID, startTime, endTime); + } + + @Test + void testFindSignalGroupAlignmentEventWithTestData() { + SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindSignalGroupAlignmentEventsWithLatestFlag() { + SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(signalGroupAlignmentEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(signalGroupAlignmentEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindSignalGroupAlignmentEventsWithPagination() { + SignalGroupAlignmentEvent event = MockEventGenerator.getSignalGroupAlignmentEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(signalGroupAlignmentEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSignalGroupAlignmentEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(signalGroupAlignmentEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountSignalGroupAlignmentEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countSignalGroupAlignmentEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountSignalGroupAlignmentEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(signalGroupAlignmentEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countSignalGroupAlignmentEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(signalGroupAlignmentEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailySignalGroupAlignmentEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailySignalGroupAlignmentEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailySignalGroupAlignmentEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(signalGroupAlignmentEventRepo.getAggregatedDailySignalGroupAlignmentEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailySignalGroupAlignmentEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(signalGroupAlignmentEventRepo, times(1)) + .getAggregatedDailySignalGroupAlignmentEventCounts(intersectionID, startTime, endTime); + } + + @Test + void testFindSignalStateConflictEventWithTestData() { + SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findSignalStateConflictEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindSignalStateConflictEventsWithLatestFlag() { + SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(signalStateConflictEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSignalStateConflictEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(signalStateConflictEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindSignalStateConflictEventsWithPagination() { + SignalStateConflictEvent event = MockEventGenerator.getSignalStateConflictEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(signalStateConflictEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSignalStateConflictEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(signalStateConflictEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountSignalStateConflictEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countSignalStateConflictEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountSignalStateConflictEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(signalStateConflictEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countSignalStateConflictEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(signalStateConflictEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailySignalStateConflictEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailySignalStateConflictEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailySignalStateConflictEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(signalStateConflictEventRepo.getAggregatedDailySignalStateConflictEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailySignalStateConflictEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(signalStateConflictEventRepo, times(1)) + .getAggregatedDailySignalStateConflictEventCounts(intersectionID, startTime, endTime); + } + + @Test + void testFindStopLinePassageEventWithTestData() { + StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findStopLinePassageEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindStopLinePassageEventsWithLatestFlag() { + StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(stopLinePassageEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLinePassageEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLinePassageEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindStopLinePassageEventsWithPagination() { + StopLinePassageEvent event = MockEventGenerator.getStopLinePassageEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(stopLinePassageEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLinePassageEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLinePassageEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountStopLinePassageEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countStopLinePassageEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountStopLinePassageEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLinePassageEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countStopLinePassageEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(stopLinePassageEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailyStopLinePassageEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailyStopLinePassageEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailyStopLinePassageEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLinePassageEventRepo.getAggregatedDailyStopLinePassageEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailyStopLinePassageEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(stopLinePassageEventRepo, times(1)).getAggregatedDailyStopLinePassageEventCounts(intersectionID, + startTime, endTime); + } + + @Test + void testFindStopLineStopEventWithTestData() { + StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findStopLineStopEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindStopLineStopEventsWithLatestFlag() { + StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(stopLineStopEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLineStopEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLineStopEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindStopLineStopEventsWithPagination() { + StopLineStopEvent event = MockEventGenerator.getStopLineStopEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(stopLineStopEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findStopLineStopEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(stopLineStopEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountStopLineStopEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countStopLineStopEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountStopLineStopEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLineStopEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countStopLineStopEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(stopLineStopEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailyStopLineStopEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailyStopLineStopEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailyStopLineStopEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(stopLineStopEventRepo.getAggregatedDailyStopLineStopEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailyStopLineStopEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(stopLineStopEventRepo, times(1)).getAggregatedDailyStopLineStopEventCounts(intersectionID, + startTime, endTime); + } + + @Test + void testFindTimeChangeDetailsEventWithTestData() { + TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindTimeChangeDetailsEventsWithLatestFlag() { + TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(timeChangeDetailsEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(timeChangeDetailsEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindTimeChangeDetailsEventsWithPagination() { + TimeChangeDetailsEvent event = MockEventGenerator.getTimeChangeDetailsEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(timeChangeDetailsEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findTimeChangeDetailsEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(timeChangeDetailsEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountTimeChangeDetailsEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countTimeChangeDetailsEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountTimeChangeDetailsEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(timeChangeDetailsEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countTimeChangeDetailsEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(timeChangeDetailsEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + public void testGetDailyTimeChangeDetailsEventCountsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getDailyTimeChangeDetailsEventCounts( + intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().size()).isEqualTo(3); // Test data should return 1L + } + + @Test + public void testGetDailyTimeChangeDetailsEventCounts() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + List expectedCounts = List.of(new IDCount("1", 5.0)); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(timeChangeDetailsEventRepo.getAggregatedDailyTimeChangeDetailsEventCounts(intersectionID, + startTime, endTime)) + .thenReturn(expectedCounts); + + ResponseEntity> response = controller.getDailyTimeChangeDetailsEventCounts( + intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCounts); + verify(timeChangeDetailsEventRepo, times(1)) + .getAggregatedDailyTimeChangeDetailsEventCounts(intersectionID, startTime, endTime); + } + + @Test + void testFindSpatMinimumDataEventWithTestData() { + SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertTrue(response.getBody().getContent().isEmpty()); // Test data should return empty + } + + @Test + void testFindSpatMinimumDataEventsWithLatestFlag() { + SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(spatMinimumDataEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatMinimumDataEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindSpatMinimumDataEventsWithPagination() { + SpatMinimumDataEvent event = MockEventGenerator.getSpatMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(spatMinimumDataEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatMinimumDataEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatMinimumDataEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountSpatMinimumDataEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countSpatMinimumDataEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountSpatMinimumDataEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(spatMinimumDataEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countSpatMinimumDataEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(spatMinimumDataEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindMapMinimumDataEventWithTestData() { + MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findMapMinimumDataEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertTrue(response.getBody().getContent().isEmpty()); // Test data should return empty + } + + @Test + void testFindMapMinimumDataEventsWithLatestFlag() { + MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(mapMinimumDataEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapMinimumDataEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapMinimumDataEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindMapMinimumDataEventsWithPagination() { + MapMinimumDataEvent event = MockEventGenerator.getMapMinimumDataEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(mapMinimumDataEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapMinimumDataEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapMinimumDataEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountMapMinimumDataEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countMapMinimumDataEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountMapMinimumDataEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(mapMinimumDataEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countMapMinimumDataEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(mapMinimumDataEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindMapBroadcastRateEventWithTestData() { + MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindMapBroadcastRateEventsWithLatestFlag() { + MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(mapBroadcastRateEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapBroadcastRateEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindMapBroadcastRateEventsWithPagination() { + MapBroadcastRateEvent event = MockEventGenerator.getMapBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(mapBroadcastRateEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapBroadcastRateEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapBroadcastRateEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountMapBroadcastRateEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countMapBroadcastRateEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountMapBroadcastRateEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(mapBroadcastRateEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countMapBroadcastRateEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(mapBroadcastRateEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindSpatBroadcastRateEventWithTestData() { + SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindSpatBroadcastRateEventsWithLatestFlag() { + SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(spatBroadcastRateEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatBroadcastRateEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindSpatBroadcastRateEventsWithPagination() { + SpatBroadcastRateEvent event = MockEventGenerator.getSpatBroadcastRateEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(spatBroadcastRateEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatBroadcastRateEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatBroadcastRateEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountSpatBroadcastRateEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countSpatBroadcastRateEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountSpatBroadcastRateEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(spatBroadcastRateEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countSpatBroadcastRateEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(spatBroadcastRateEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindSpatMessageCountProgressionEventWithTestData() { + SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindSpatMessageCountProgressionEventsWithLatestFlag() { + SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(spatMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindSpatMessageCountProgressionEventsWithPagination() { + SpatMessageCountProgressionEvent event = MockEventGenerator.getSpatMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(spatMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findSpatMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(spatMessageCountProgressionEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountSpatMessageCountProgressionEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countSpatMessageCountProgressionEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountSpatMessageCountProgressionEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(spatMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countSpatMessageCountProgressionEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(spatMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + void testFindMapMessageCountProgressionEventWithTestData() { + MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindMapMessageCountProgressionEventsWithLatestFlag() { + MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(mapMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindMapMessageCountProgressionEventsWithPagination() { + MapMessageCountProgressionEvent event = MockEventGenerator.getMapMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(mapMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findMapMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(mapMessageCountProgressionEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountMapMessageCountProgressionEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countMapMessageCountProgressionEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountMapMessageCountProgressionEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(mapMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countMapMessageCountProgressionEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(mapMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindBsmMessageCountProgressionEventWithTestData() { + BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindBsmMessageCountProgressionEventsWithLatestFlag() { + BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(bsmMessageCountProgressionEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(bsmMessageCountProgressionEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindBsmMessageCountProgressionEventsWithPagination() { + BsmMessageCountProgressionEvent event = MockEventGenerator.getBsmMessageCountProgressionEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(bsmMessageCountProgressionEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findBsmMessageCountProgressionEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(bsmMessageCountProgressionEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountBsmMessageCountProgressionEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countBsmMessageCountProgressionEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountBsmMessageCountProgressionEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(bsmMessageCountProgressionEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countBsmMessageCountProgressionEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(bsmMessageCountProgressionEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testFindBsmEventWithTestData() { + BsmEvent event = MockEventGenerator.getBsmEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean testData = true; + + ResponseEntity> response = controller + .findBsmEvents(event.getIntersectionID(), null, null, false, + 0, 10, + testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertFalse(response.getBody().getContent().isEmpty()); + } + + @Test + void testFindBsmEventsWithLatestFlag() { + BsmEvent event = MockEventGenerator.getBsmEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = true; + + Page mockPage = new PageImpl<>(events); + when(bsmEventRepo.findLatest(eq(event.getIntersectionID()), any(), any())) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findBsmEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(bsmEventRepo, times(1)).findLatest(eq(event.getIntersectionID()), + any(), any()); + } + + @Test + void testFindBsmEventsWithPagination() { + BsmEvent event = MockEventGenerator.getBsmEvent(); + + List events = new ArrayList<>(); + events.add(event); + + when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + boolean latest = false; + + Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); + when(bsmEventRepo.find(eq(event.getIntersectionID()), any(), any(), + any(PageRequest.class))) + .thenReturn(mockPage); + + ResponseEntity> response = controller + .findBsmEvents(event.getIntersectionID(), null, null, latest, + 0, 10, + false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEqualTo(events); + verify(bsmEventRepo, times(1)) + .find(eq(event.getIntersectionID()), any(), any(), any(PageRequest.class)); + } + + @Test + public void testCountBsmEventsWithTestData() { + Integer intersectionID = 1; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity response = controller.countBsmEvents(intersectionID, + null, null, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(1L); // Test data should return 1L + } + + @Test + public void testCountBsmEvents() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + Long expectedCount = 5L; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(bsmEventRepo.count(intersectionID, startTime, endTime)) + .thenReturn(expectedCount); + + ResponseEntity response = controller.countBsmEvents(intersectionID, + startTime, endTime, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expectedCount); + verify(bsmEventRepo, times(1)).count(intersectionID, startTime, endTime); + } + + @Test + void testGetBsmActivityByMinuteInRangeWithTestData() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + boolean testData = true; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + ResponseEntity> response = controller.getBsmActivityByMinuteInRange( + intersectionID, startTime, endTime, false, 0, 10, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isNotEmpty(); + assertThat(response.getBody().getContent().size()).isEqualTo(10); // Test data generates 10 items + } + + @Test + void testGetBsmActivityByMinuteInRangeWithLatestFlag() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + boolean latest = true; + boolean testData = false; + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + Page mockPage = new PageImpl<>(Collections.emptyList()); + when(bsmEventRepo.findLatest(intersectionID, startTime, endTime)).thenReturn(mockPage); + + ResponseEntity> response = controller.getBsmActivityByMinuteInRange( + intersectionID, startTime, endTime, latest, 0, 10, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isEmpty(); // No events in the mock repository + verify(bsmEventRepo, times(1)).findLatest(intersectionID, startTime, endTime); + } + + @Test + void testGetBsmActivityByMinuteInRangeWithPagination() { + Integer intersectionID = 1; + Long startTime = 1000L; + Long endTime = 2000L; + boolean latest = false; + boolean testData = false; + J2735Bsm bsm = new J2735Bsm(); + J2735BsmCoreData coreData = new J2735BsmCoreData(); + coreData.setId("id"); + bsm.setCoreData(coreData); + + when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + BsmEvent mockEvent = new BsmEvent(); + // mockEvent.setStartingBsm(new OdeBsmData(new OdeMsgMetadata(), new + // OdeMsgPayload(bsm))); + // mockEvent.setEndingBsm(new OdeBsmData(new OdeMsgMetadata(), new + // OdeMsgPayload())); + + mockEvent.setStartingBsm(MockBsmGenerator.getProcessedBsms().getFirst()); + mockEvent.setEndingBsm(MockBsmGenerator.getProcessedBsms().getFirst()); + + Page mockPage = new PageImpl<>(Collections.singletonList(mockEvent), PageRequest.of(0, 10), + 1); + when(bsmEventRepo.find(intersectionID, startTime, endTime, PageRequest.of(0, 10))).thenReturn(mockPage); + + ResponseEntity> response = controller.getBsmActivityByMinuteInRange( + intersectionID, startTime, endTime, latest, 0, 10, testData); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isNotEmpty(); + verify(bsmEventRepo, times(1)).find(intersectionID, startTime, endTime, PageRequest.of(0, 10)); + } } diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmNotificationControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmNotificationControllerTest.java index efc68b30e..c461f9f50 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmNotificationControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/CmNotificationControllerTest.java @@ -12,7 +12,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -22,9 +21,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.IntersectionReferenceAlignmentNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.LaneDirectionOfTravelNotification; @@ -45,13 +44,13 @@ import us.dot.its.jpo.ode.api.accessors.notifications.stop_line_passage_notification.StopLinePassageNotificationRepository; import us.dot.its.jpo.ode.api.accessors.notifications.stop_line_stop_notification.StopLineStopNotificationRepository; import us.dot.its.jpo.ode.api.accessors.notifications.time_change_details_notification.TimeChangeDetailsNotificationRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockNotificationGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class CmNotificationControllerTest { private final CmNotificationController controller; @@ -102,7 +101,7 @@ void testFindConnectionOfTravelNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -122,7 +121,7 @@ void testFindConnectionOfTravelNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -148,7 +147,7 @@ void testFindConnectionOfTravelNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -173,7 +172,7 @@ public void testCountConnectionOfTravelNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countConnectionOfTravelNotifications(intersectionID, null, null, testData); @@ -190,7 +189,7 @@ public void testCountConnectionOfTravelNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(connectionOfTravelNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -211,7 +210,7 @@ void testFindIntersectionReferenceAlignmentNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -233,7 +232,7 @@ void testFindIntersectionReferenceAlignmentNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -263,7 +262,7 @@ void testFindIntersectionReferenceAlignmentNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, @@ -290,7 +289,7 @@ public void testCountIntersectionReferenceAlignmentNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countIntersectionReferenceAlignmentNotifications( intersectionID, @@ -308,7 +307,7 @@ public void testCountIntersectionReferenceAlignmentNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(intersectionReferenceAlignmentNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -331,7 +330,7 @@ void testFindLaneDirectionOfTravelNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -352,7 +351,7 @@ void testFindLaneDirectionOfTravelNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -379,7 +378,7 @@ void testFindLaneDirectionOfTravelNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -404,7 +403,7 @@ public void testCountLaneDirectionOfTravelNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countLaneDirectionOfTravelNotifications(intersectionID, null, null, testData); @@ -421,7 +420,7 @@ public void testCountLaneDirectionOfTravelNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(laneDirectionOfTravelNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -441,7 +440,7 @@ void testFindMapBroadcastRateNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -461,7 +460,7 @@ void testFindMapBroadcastRateNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -487,7 +486,7 @@ void testFindMapBroadcastRateNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -512,7 +511,7 @@ public void testCountMapBroadcastRateNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countMapBroadcastRateNotifications(intersectionID, null, null, testData); @@ -529,7 +528,7 @@ public void testCountMapBroadcastRateNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(mapBroadcastRateNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -550,7 +549,7 @@ void testFindSignalGroupAlignmentNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -571,7 +570,7 @@ void testFindSignalGroupAlignmentNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -598,7 +597,7 @@ void testFindSignalGroupAlignmentNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -623,7 +622,7 @@ public void testCountSignalGroupAlignmentNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countSignalGroupAlignmentNotifications(intersectionID, null, null, testData); @@ -640,7 +639,7 @@ public void testCountSignalGroupAlignmentNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(signalGroupAlignmentNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -660,7 +659,7 @@ void testFindSignalStateConflictNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -680,7 +679,7 @@ void testFindSignalStateConflictNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -706,7 +705,7 @@ void testFindSignalStateConflictNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -731,7 +730,7 @@ public void testCountSignalStateConflictNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countSignalStateConflictNotifications(intersectionID, null, null, testData); @@ -748,7 +747,7 @@ public void testCountSignalStateConflictNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(signalStateConflictNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -768,7 +767,7 @@ void testFindSpatBroadcastRateNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -788,7 +787,7 @@ void testFindSpatBroadcastRateNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -814,7 +813,7 @@ void testFindSpatBroadcastRateNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -839,7 +838,7 @@ public void testCountSpatBroadcastRateNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countSpatBroadcastRateNotifications(intersectionID, null, null, testData); @@ -856,7 +855,7 @@ public void testCountSpatBroadcastRateNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(spatBroadcastRateNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -876,7 +875,7 @@ void testFindStopLineStopNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -896,7 +895,7 @@ void testFindStopLineStopNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -922,7 +921,7 @@ void testFindStopLineStopNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -947,7 +946,7 @@ public void testCountStopLineStopNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countStopLineStopNotifications(intersectionID, null, null, testData); @@ -964,7 +963,7 @@ public void testCountStopLineStopNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(stopLineStopNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -984,7 +983,7 @@ void testFindStopLinePassageNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -1004,7 +1003,7 @@ void testFindStopLinePassageNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -1030,7 +1029,7 @@ void testFindStopLinePassageNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -1055,7 +1054,7 @@ public void testCountStopLinePassageNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countStopLinePassageNotifications(intersectionID, null, null, testData); @@ -1072,7 +1071,7 @@ public void testCountStopLinePassageNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(stopLinePassageNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); @@ -1092,7 +1091,7 @@ void testFindTimeChangeDetailsNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -1112,7 +1111,7 @@ void testFindTimeChangeDetailsNotificationsWithLatestFlag() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = true; Page mockPage = new PageImpl<>(events); @@ -1138,7 +1137,7 @@ void testFindTimeChangeDetailsNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean latest = false; Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); @@ -1163,7 +1162,7 @@ public void testCountTimeChangeDetailsNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countTimeChangeDetailsNotifications(intersectionID, null, null, testData); @@ -1180,7 +1179,7 @@ public void testCountTimeChangeDetailsNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(timeChangeDetailsNotificationRepo.count(intersectionID, startTime, endTime)) .thenReturn(expectedCount); diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/HaasControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/HaasControllerTest.java index 0680a9bd7..a5565e258 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/HaasControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/HaasControllerTest.java @@ -1,36 +1,40 @@ package us.dot.its.jpo.ode.api.controllers.data; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; import org.junit.Test; -import org.junit.runner.RunWith; +import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.ode.api.accessors.haas.HaasLocationDataRepository; import us.dot.its.jpo.ode.api.models.LimitedGeoJsonResponse; import us.dot.its.jpo.ode.api.models.haas.HaasLocation; import us.dot.its.jpo.ode.api.models.haas.HaasLocationResult; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockHaasGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@AutoConfigureEmbeddedDatabase -@ActiveProfiles("test") +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) public class HaasControllerTest { - private final HaasController controller; + @MockitoBean + private HaasController controller; @MockitoBean HaasLocationDataRepository haasLocationDataRepository; @@ -38,10 +42,8 @@ public class HaasControllerTest { @MockitoBean PermissionService permissionService; - @Autowired - public HaasControllerTest(HaasController controller) { - this.controller = controller; - } + @Mock + private CvManagerAuthToken authToken; @Test public void testGetLocations() { @@ -50,7 +52,8 @@ public void testGetLocations() { List locations = new ArrayList<>(); locations.add(location); - when(permissionService.isSuperUser()).thenReturn(true); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(true).when(authToken).isSuperUser(); HaasLocationResult mockResult = new HaasLocationResult(locations, false); when(haasLocationDataRepository.findWithLimit(true, null, null, 1000)) @@ -70,7 +73,8 @@ public void testGetLocationsWithTruncation() { List locations = new ArrayList<>(); locations.add(location); - when(permissionService.isSuperUser()).thenReturn(true); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(true).when(authToken).isSuperUser(); HaasLocationResult mockResult = new HaasLocationResult(locations, true); when(haasLocationDataRepository.findWithLimit(true, null, null, 1)) diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/MapControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/MapControllerTest.java index dd29e5059..e834b14b4 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/MapControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/MapControllerTest.java @@ -7,7 +7,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -17,19 +16,19 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.geojsonconverter.pojos.geojson.LineString; import us.dot.its.jpo.geojsonconverter.pojos.geojson.map.ProcessedMap; import us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockMapGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class MapControllerTest { private final MapController controller; @@ -52,8 +51,9 @@ public void testProcessedMap() { List> maps = new ArrayList<>(); maps.add(map); - when(permissionService.hasIntersection(map.getProperties().getIntersectionId(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasIntersection(map.getProperties().getIntersectionId(), "USER")) + .thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); PageRequest page = PageRequest.of(1, 1); when(processedMapRepo.find(map.getProperties().getIntersectionId(), diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/SpatControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/SpatControllerTest.java index 1b9b349e4..1ce04c41c 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/SpatControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/data/SpatControllerTest.java @@ -7,7 +7,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -17,18 +16,18 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.geojsonconverter.pojos.spat.ProcessedSpat; import us.dot.its.jpo.ode.api.accessors.spat.ProcessedSpatRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockSpatGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@AutoConfigureEmbeddedDatabase -@ActiveProfiles("test") +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class SpatControllerTest { private final SpatController controller; @@ -52,7 +51,7 @@ public void testProcessedSpat() { spats.add(spat); when(permissionService.hasIntersection(spat.getIntersectionId(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); PageRequest page = PageRequest.of(1, 1); when(processedSpatRepo.find(spat.getIntersectionId(), diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/RsuControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/RsuControllerTest.java new file mode 100644 index 000000000..f105e3a90 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/RsuControllerTest.java @@ -0,0 +1,730 @@ +package us.dot.its.jpo.ode.api.controllers.devices; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ResponseStatusException; + +import us.dot.its.jpo.ode.api.models.devices.RsuInfoDto; +import us.dot.its.jpo.ode.api.models.devices.management.ModifyRsuAllowedSelections; +import us.dot.its.jpo.ode.api.models.devices.management.RsuPatch; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.models.SimplePosition; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.services.PermissionService; +import us.dot.its.jpo.ode.api.services.RsuManagementService; +import us.dot.its.jpo.ode.api.services.RsuOptionManagementService; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RsuControllerTest { + + @Mock + private Authentication authentication; + + @Mock + private RsuManagementService rsuManagementService; + + @Mock + private RsuOptionManagementService rsuOptionManagementService; + + @Mock + private PermissionService permissionService; + + @Mock + private SecurityContext securityContext; + + @Mock + private CvManagerAuthToken authToken; + + @InjectMocks + private RsuController rsuController; + + @Nested + @DisplayName("Tests for getAllRsus endpoint") + class GetAllRsusTests { + @Test + void testGetAllRsus_Success() { + String organization = "TestOrg"; + String search = "Search Term"; + Pageable pageable = PageRequest.of(0, 100); + + RsuInfoDto rsu1 = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU1", + "SCMS1", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + + RsuInfoDto rsu2 = new RsuInfoDto( + "192.168.1.101", + new SimplePosition(39.7400, -105.0850), + 124.5, + "I-70", + "RSU2", + "SCMS2", + "Yunex RSU-2X", + "ssh-group-2", + "snmp-group-2", + "v2c", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + + List rsuList = Arrays.asList(rsu1, rsu2); + Page rsuPage = new PageImpl<>(rsuList, pageable, 2); + + when(rsuManagementService.getAllRsuInfo(organization, search, pageable)).thenReturn(rsuPage); + + Page result = rsuController.getAllRsus(organization, search, pageable); + + assertNotNull(result); + assertEquals(2, result.getTotalElements()); + assertEquals(2, result.getContent().size()); + assertEquals("192.168.1.100", result.getContent().get(0).getIpv4Address()); + assertEquals("192.168.1.101", result.getContent().get(1).getIpv4Address()); + + verify(rsuManagementService).getAllRsuInfo(organization, search, pageable); + } + + @Test + void testGetAllRsus_Sorting_TimDeposit() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by(Sort.Direction.ASC, "tim_deposit")); + Pageable expectedMappedPageable = PageRequest.of(0, 100, + Sort.by(Sort.Direction.ASC, "rsuOption.timDeposit")); + + Page emptyPage = new PageImpl<>(List.of(), expectedMappedPageable, 0); + + when(rsuManagementService.getAllRsuInfo(eq(organization), eq(search), eq(expectedMappedPageable))) + .thenReturn(emptyPage); + + Page result = rsuController.getAllRsus(organization, search, pageable); + + assertNotNull(result); + verify(rsuManagementService).getAllRsuInfo(eq(organization), eq(search), eq(expectedMappedPageable)); + } + + @Test + void testGetAllRsus_Sorting_SnmpMonitoring() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by(Sort.Direction.ASC, "snmp_monitoring")); + Pageable expectedMappedPageable = PageRequest.of(0, 100, + Sort.by(Sort.Direction.ASC, "rsuOption.snmpMonitoring")); + + Page emptyPage = new PageImpl<>(List.of(), expectedMappedPageable, 0); + + when(rsuManagementService.getAllRsuInfo(eq(organization), eq(search), eq(expectedMappedPageable))) + .thenReturn(emptyPage); + + Page result = rsuController.getAllRsus(organization, search, pageable); + + assertNotNull(result); + verify(rsuManagementService).getAllRsuInfo(eq(organization), eq(search), eq(expectedMappedPageable)); + } + + @Test + void testGetAllRsus_EmptyResult() { + String organization = "EmptyOrg"; + String search = "Search Term"; + Pageable pageable = PageRequest.of(0, 100); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + when(rsuManagementService.getAllRsuInfo(organization, search, pageable)).thenReturn(emptyPage); + + Page result = rsuController.getAllRsus(organization, search, pageable); + + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + + verify(rsuManagementService).getAllRsuInfo(organization, search, pageable); + } + + @Test + void testGetAllRsus_WithCustomPageSize() { + String organization = "TestOrg"; + String search = "Search Term"; + Pageable pageable = PageRequest.of(0, 50); + + RsuInfoDto rsu1 = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU1", + "SCMS1", + "Model X", + "ssh-group", + "snmp-group", + "v3", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + + Page rsuPage = new PageImpl<>(List.of(rsu1), pageable, 1); + + when(rsuManagementService.getAllRsuInfo(organization, search, pageable)).thenReturn(rsuPage); + + Page result = rsuController.getAllRsus(organization, search, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals(50, result.getPageable().getPageSize()); + + verify(rsuManagementService).getAllRsuInfo(organization, search, pageable); + } + } + + @Nested + @DisplayName("Tests for getSingleRsuData endpoint") + class GetSingleRsuDataTests { + @Test + void testGetSingleRsuData_Success() { + String rsuIp = "192.168.1.100"; + + RsuInfoDto rsuInfo = new RsuInfoDto( + rsuIp, + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + + when(rsuManagementService.getRsuInfo(rsuIp)).thenReturn(rsuInfo); + + RsuInfoDto result = rsuController.getSingleRsuData(rsuIp); + + assertNotNull(result); + + assertEquals(rsuIp, result.getIpv4Address()); + assertEquals("I-25", result.getPrimaryRoute()); + + verify(rsuManagementService).getRsuInfo(rsuIp); + } + + @Test + void testGetSingleRsuData_RsuNotFound() { + String rsuIp = "192.168.1.999"; + + when(rsuManagementService.getRsuInfo(rsuIp)).thenReturn(null); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuController.getSingleRsuData(rsuIp)); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); + assertEquals("RSU not found", exception.getReason()); + + verify(rsuManagementService).getRsuInfo(rsuIp); + } + + @Test + void testGetSingleRsuData_InvalidIpAddress() { + String invalidRsuIp = "invalid-ip"; + + when(rsuManagementService.getRsuInfo(invalidRsuIp)) + .thenThrow( + new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid IP address: " + invalidRsuIp)); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.getSingleRsuData(invalidRsuIp)); + + verify(rsuManagementService).getRsuInfo(invalidRsuIp); + } + } + + @Nested + @DisplayName("Tests for getAllowedSelections endpoint") + class GetAllowedSelectionsTests { + @Test + void testGetAllowedSelections_Success() { + + ModifyRsuAllowedSelections allowedSelections = new ModifyRsuAllowedSelections( + Arrays.asList("I-25", "I-70"), + Arrays.asList("Commsignia ITS-RS4-M", "Yunex RSU-2X"), + Arrays.asList("ssh-group-1", "ssh-group-2"), + Arrays.asList("snmp-group-1", "snmp-group-2"), + Arrays.asList("v2c", "v3"), + Arrays.asList("TestOrg", "OtherOrg")); + + when(rsuManagementService.getAllowedSelections(any(CvManagerAuthToken.class))) + .thenReturn(allowedSelections); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + + ModifyRsuAllowedSelections result = rsuController.getAllowedSelections(); + + assertNotNull(result); + + assertEquals(2, result.getPrimaryRoutes().size()); + assertEquals(2, result.getRsuModels().size()); + assertEquals(2, result.getSshCredentialGroups().size()); + assertEquals(2, result.getSnmpCredentialGroups().size()); + assertEquals(2, result.getSnmpVersionGroups().size()); + assertEquals(2, result.getOrganizations().size()); + + verify(rsuManagementService).getAllowedSelections(any(CvManagerAuthToken.class)); + } + + @Nested + @DisplayName("Tests for modifyRsu endpoint") + class ModifyRsuTests { + @Test + void testModifyRsu_Success() { + String rsuIp = "192.168.1.100"; + RsuPatch patch = new RsuPatch(); + patch.setIpv4Address("192.168.1.101"); + + doReturn(null).when(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + doNothing().when(rsuOptionManagementService).modifyRsuOption(rsuIp, patch); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + + ResponseEntity result = rsuController.modifyRsu(rsuIp, patch); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + assertNull(result.getBody()); + + verify(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + verify(rsuOptionManagementService).modifyRsuOption(rsuIp, patch); + } + + @Test + void testModifyRsu_RsuNotFound() { + String rsuIp = "192.168.1.999"; + RsuPatch patch = new RsuPatch(); + + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "RSU not found")) + .when(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + assertThrows( + ResponseStatusException.class, + () -> rsuController.modifyRsu(rsuIp, patch)); + + verify(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + verify(rsuOptionManagementService, never()).modifyRsuOption(any(), any()); + } + + @Test + void testModifyRsu_InvalidPatch() { + String rsuIp = "192.168.1.100"; + RsuPatch invalidPatch = new RsuPatch(); + invalidPatch.setIpv4Address("invalid-ip"); + + doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid IP address")) + .when(rsuManagementService).modifyRsu(rsuIp, invalidPatch, authToken); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.modifyRsu(rsuIp, invalidPatch)); + + verify(rsuManagementService).modifyRsu(rsuIp, invalidPatch, authToken); + verify(rsuOptionManagementService, never()).modifyRsuOption(any(), any()); + } + + @Test + void testModifyRsu_ServiceException() { + String rsuIp = "192.168.1.100"; + RsuPatch patch = new RsuPatch(); + + doThrow(new RuntimeException("Database error")) + .when(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + assertThrows( + RuntimeException.class, + () -> rsuController.modifyRsu(rsuIp, patch)); + + verify(rsuManagementService).modifyRsu(rsuIp, patch, authToken); + verify(rsuOptionManagementService, never()).modifyRsuOption(any(), any()); + } + } + + @Nested + @DisplayName("Tests for deleteRsu endpoint") + class DeleteRsuTests { + @Test + void testDeleteRsu_Success() { + String rsuIp = "192.168.1.100"; + + doNothing().when(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + + ResponseEntity result = rsuController.deleteRsu(rsuIp); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + assertNull(result.getBody()); + + verify(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + } + + @Test + void testDeleteRsu_RsuNotFound() { + String rsuIp = "192.168.1.999"; + + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "RSU not found")) + .when(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.deleteRsu(rsuIp)); + + verify(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + } + + @Test + void testDeleteRsu_InvalidIpAddress() { + String invalidRsuIp = "invalid-ip"; + + doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid IP address: " + invalidRsuIp)) + .when(rsuManagementService).deleteRsuByIpv4Address(invalidRsuIp); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.deleteRsu(invalidRsuIp)); + + verify(rsuManagementService).deleteRsuByIpv4Address(invalidRsuIp); + } + + @Test + void testDeleteRsu_ServiceException() { + String rsuIp = "192.168.1.100"; + + doThrow(new RuntimeException("Database connection failed")) + .when(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + + assertThrows( + RuntimeException.class, + () -> rsuController.deleteRsu(rsuIp)); + + verify(rsuManagementService).deleteRsuByIpv4Address(rsuIp); + } + } + + @Nested + @DisplayName("Tests for deleteRsus (multiple) endpoint") + class DeleteMultipleRsusTests { + @Test + void testDeleteRsus_Success() { + List rsuIps = Arrays.asList("192.168.1.100", "192.168.1.101", "192.168.1.102"); + + doNothing().when(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + + ResponseEntity result = rsuController.deleteRsus(rsuIps); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + assertNull(result.getBody()); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + } + + @Test + void testDeleteRsus_SingleRsu() { + List rsuIps = Arrays.asList("192.168.1.100"); + + doNothing().when(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + + ResponseEntity result = rsuController.deleteRsus(rsuIps); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + } + + @Test + void testDeleteRsus_EmptyList() { + List emptyList = Arrays.asList(); + + doNothing().when(rsuManagementService).deleteMultipleRsusByIpv4Address(emptyList); + + ResponseEntity result = rsuController.deleteRsus(emptyList); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(emptyList); + } + + @Test + void testDeleteRsus_SomeNotFound() { + List rsuIps = Arrays.asList("192.168.1.100", "192.168.1.999", "192.168.1.101"); + + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "Some RSUs not found")) + .when(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.deleteRsus(rsuIps)); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + } + + @Test + void testDeleteRsus_InvalidIpInList() { + List rsuIps = Arrays.asList("192.168.1.100", "invalid-ip", "192.168.1.101"); + + doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid IP address: invalid-ip")) + .when(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.deleteRsus(rsuIps)); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + } + + @Test + void testDeleteRsus_LargeList() { + List largeList = Arrays.asList( + "192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5", + "192.168.1.6", "192.168.1.7", "192.168.1.8", "192.168.1.9", "192.168.1.10"); + + doNothing().when(rsuManagementService).deleteMultipleRsusByIpv4Address(largeList); + + ResponseEntity result = rsuController.deleteRsus(largeList); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(largeList); + } + + @Test + void testDeleteRsus_ServiceException() { + List rsuIps = Arrays.asList("192.168.1.100", "192.168.1.101"); + + doThrow(new RuntimeException("Database transaction failed")) + .when(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + + assertThrows( + RuntimeException.class, + () -> rsuController.deleteRsus(rsuIps)); + + verify(rsuManagementService).deleteMultipleRsusByIpv4Address(rsuIps); + } + } + + @Nested + @DisplayName("Tests for createRsu endpoint") + class CreateRsuTests { + @Test + void testCreateRsu_Success() { + List orgsToAdd = Arrays.asList("TestOrg"); + UserRole role = UserRole.OPERATOR; + + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + orgsToAdd, + true, + true); + + Rsu mockRsu = new Rsu(); + + when(permissionService.hasRoleInOrgs(role, orgsToAdd)).thenReturn(true); + when(rsuManagementService.createRsu(rsuInfoDto, orgsToAdd)).thenReturn(mockRsu); + + ResponseEntity result = rsuController.createRsu(rsuInfoDto); + + assertNotNull(result); + assertEquals(HttpStatus.CREATED, result.getStatusCode()); + assertNull(result.getBody()); + + verify(permissionService).hasRoleInOrgs(role, orgsToAdd); + verify(rsuManagementService).createRsu(rsuInfoDto, orgsToAdd); + } + + @Test + void testCreateRsu_UnqualifiedOrganization() { + List orgsToAdd = Arrays.asList("TestOrg", "UnqualifiedOrg"); + UserRole role = UserRole.OPERATOR; + + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + orgsToAdd, + true, + true); + + when(permissionService.hasRoleInOrgs(role, orgsToAdd)).thenReturn(false); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuController.createRsu(rsuInfoDto)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertTrue(exception.getReason().contains("User not qualified to modify all specified organizations")); + + verify(rsuManagementService, never()).createRsu(any(), anyList()); + } + + @Test + void testCreateRsu_DuplicateIpAddress() { + List orgsToAdd = Arrays.asList("TestOrg"); + UserRole role = UserRole.OPERATOR; + + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + orgsToAdd, + true, + true); + + when(permissionService.hasRoleInOrgs(role, orgsToAdd)).thenReturn(true); + + when(rsuManagementService.createRsu(rsuInfoDto, orgsToAdd)) + .thenThrow(new ResponseStatusException(HttpStatus.CONFLICT, + "RSU with IP 192.168.1.100 already exists")); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.createRsu(rsuInfoDto)); + + verify(rsuManagementService).createRsu(rsuInfoDto, orgsToAdd); + } + } + + @Test + void testCreateRsu_ServiceException() { + List orgsToAdd = Arrays.asList("TestOrg"); + + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + orgsToAdd, + true, + true); + + assertThrows( + RuntimeException.class, + () -> rsuController.createRsu(rsuInfoDto)); + } + + @Test + void testCreateRsu_OrgRelationshipCreationFails() { + List orgsToAdd = Arrays.asList("TestOrg"); + + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + orgsToAdd, + true, + true); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.createRsu(rsuInfoDto)); + } + + @Test + void testCreateRsu_NullOrganizationsList() { + RsuInfoDto rsuInfoDto = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Commsignia ITS-RS4-M", + "ssh-group-1", + "snmp-group-1", + "v3", + null, + true, + true); + + assertThrows( + ResponseStatusException.class, + () -> rsuController.createRsu(rsuInfoDto)); + + verify(rsuManagementService, never()).createRsu(any(), anyList()); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/InfoControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/InfoControllerTest.java new file mode 100644 index 000000000..bc62cd767 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/InfoControllerTest.java @@ -0,0 +1,151 @@ +package us.dot.its.jpo.ode.api.controllers.devices.rsus; + +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.mockito.BDDMockito.given; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.geojson.GeoJsonPointDto; +import us.dot.its.jpo.ode.api.models.postgres.dtos.RsuGeoInfoDto; +import us.dot.its.jpo.ode.api.models.postgres.dtos.RsuGeoInfoPropertiesDto; +import us.dot.its.jpo.ode.api.services.PermissionService; +import us.dot.its.jpo.ode.api.services.RsuInfoService; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) +class InfoControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + RsuInfoService rsuInfoService; + + @MockitoBean + PermissionService permissionService; + + private RsuGeoInfoDto buildFeature(int rsuId) { + RsuGeoInfoPropertiesDto props = new RsuGeoInfoPropertiesDto( + rsuId, + 12.5, + "10.0.0." + rsuId, + "SN-" + rsuId, + "I-999", + true, + false, + "ITS RS4", + "Commsignia"); + GeoJsonPointDto geometry = new GeoJsonPointDto(new double[] { -104.9903, 39.7392 }); + return new RsuGeoInfoDto(rsuId, geometry, props); + } + + // --- GET /devices/rsus/info --- + + @Test + void getRsuInfo_SuperUser_ReturnsOkWithRsuList() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + given(rsuInfoService.getRsuGeoInfoByOrganization("TestOrg")) + .willReturn(List.of(buildFeature(1), buildFeature(2))); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt()) + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rsuList").isArray()) + .andExpect(jsonPath("$.rsuList.length()").value(2)); + } + + @Test + void getRsuInfo_ReturnsCorrectGeoJsonStructure() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + given(rsuInfoService.getRsuGeoInfoByOrganization("TestOrg")) + .willReturn(List.of(buildFeature(1))); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt()) + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rsuList[0].type").value("Feature")) + .andExpect(jsonPath("$.rsuList[0].id").value(1)) + .andExpect(jsonPath("$.rsuList[0].geometry.type").value("Point")) + .andExpect(jsonPath("$.rsuList[0].geometry.coordinates[0]").value(-104.9903)) + .andExpect(jsonPath("$.rsuList[0].geometry.coordinates[1]").value(39.7392)) + .andExpect(jsonPath("$.rsuList[0].properties.rsu_id").value(1)) + .andExpect(jsonPath("$.rsuList[0].properties.ipv4_address").value("10.0.0.1")) + .andExpect(jsonPath("$.rsuList[0].properties.serial_number").value("SN-1")) + .andExpect(jsonPath("$.rsuList[0].properties.primary_route").value("I-999")) + .andExpect(jsonPath("$.rsuList[0].properties.milepost").value(12.5)) + .andExpect(jsonPath("$.rsuList[0].properties.tim_deposit").value(true)) + .andExpect(jsonPath("$.rsuList[0].properties.snmp_monitoring").value(false)) + .andExpect(jsonPath("$.rsuList[0].properties.model_name").value("ITS RS4")) + .andExpect(jsonPath("$.rsuList[0].properties.manufacturer_name").value("Commsignia")); + } + + @Test + void getRsuInfo_EmptyOrganization_ReturnsOkWithEmptyList() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + given(rsuInfoService.getRsuGeoInfoByOrganization("EmptyOrg")) + .willReturn(List.of()); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt()) + .header("Organization", "EmptyOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rsuList").isArray()) + .andExpect(jsonPath("$.rsuList.length()").value(0)); + } + + @Test + void getRsuInfo_HasUserRole_ReturnsOk() throws Exception { + given(permissionService.isSuperUser()).willReturn(false); + given(permissionService.hasRole(UserRole.USER)).willReturn(true); + given(rsuInfoService.getRsuGeoInfoByOrganization("TestOrg")) + .willReturn(List.of(buildFeature(5))); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt()) + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rsuList.length()").value(1)); + } + + @Test + void getRsuInfo_MissingOrganizationHeader_ReturnsBadRequest() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt())) + .andExpect(status().isBadRequest()); + } + + @Test + void getRsuInfo_Forbidden_ReturnsForbidden() throws Exception { + given(permissionService.isSuperUser()).willReturn(false); + given(permissionService.hasRole(UserRole.USER)).willReturn(false); + + mockMvc.perform(get("/devices/rsus/info") + .with(jwt()) + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } + + @Test + void getRsuInfo_Unauthenticated_ReturnsForbidden() throws Exception { + mockMvc.perform(get("/devices/rsus/info") + .header("Organization", "TestOrg")) + .andExpect(status().isForbidden()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/UpgradeControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/UpgradeControllerTest.java new file mode 100644 index 000000000..94123f43f --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/devices/rsus/UpgradeControllerTest.java @@ -0,0 +1,169 @@ +package us.dot.its.jpo.ode.api.controllers.devices.rsus; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.models.devices.management.RsuUpgradeRequest; +import us.dot.its.jpo.ode.api.models.postgres.dtos.FirmwareUpgradeCheckResponseDto; +import us.dot.its.jpo.ode.api.models.postgres.dtos.FirmwareUpgradeResultDto; +import us.dot.its.jpo.ode.api.services.PermissionService; +import us.dot.its.jpo.ode.api.services.RsuUpgradeService; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) +class UpgradeControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + RsuUpgradeService rsuUpgradeService; + + @MockitoBean + PermissionService permissionService; + + // POST /devices/rsus/upgrade (startUpgrade) Tests + + @Test + void startUpgrade_Success() throws Exception { + List rsuIps = List.of("10.0.0.10", "10.0.0.11"); + Map serviceResponse = Map.of( + "rsu1", new FirmwareUpgradeResultDto(200, "ok")); + + given(permissionService.isSuperUser()).willReturn(true); + given(rsuUpgradeService.startFirmwareUpgradeForRsus(anyList())).willReturn(serviceResponse); + + RsuUpgradeRequest request = new RsuUpgradeRequest(); + request.setRsuIps(rsuIps); + + mockMvc.perform(post("/devices/rsus/upgrade") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rsu1.code").value(200)); + } + + @Test + void startUpgrade_WithoutOrganizationHeader_ReturnsOk() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + given(rsuUpgradeService.startFirmwareUpgradeForRsus(anyList())).willReturn(Map.of()); + + RsuUpgradeRequest request = new RsuUpgradeRequest(); + request.setRsuIps(List.of("10.0.0.10")); + + mockMvc.perform(post("/devices/rsus/upgrade") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void startUpgrade_EmptyRsuIpList_ReturnsBadRequest() throws Exception { + mockMvc.perform(post("/devices/rsus/upgrade") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ips\": []}")) + .andExpect(status().isBadRequest()); + } + + @Test + void startUpgrade_NullRsuIpField_ReturnsBadRequest() throws Exception { + mockMvc.perform(post("/devices/rsus/upgrade") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + void startUpgrade_Forbidden_ReturnsForbidden() throws Exception { + given(permissionService.isSuperUser()).willReturn(false); + given(permissionService.hasRsus(any(), anyString())).willReturn(false); + + mockMvc.perform(post("/devices/rsus/upgrade") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ips\": [\"10.0.0.10\"]}")) + .andExpect(status().isForbidden()); + } + + // POST /devices/rsus/upgrade/check (checkUpgrade) Tests + + @Test + void checkUpgrade_Success() throws Exception { + FirmwareUpgradeCheckResponseDto serviceResponse = new FirmwareUpgradeCheckResponseDto( + true, 42L, "RSU Firmware v2.0", "2.0"); + + given(permissionService.isSuperUser()).willReturn(true); + given(rsuUpgradeService.checkFirmwareUpgrade(anyString())).willReturn(serviceResponse); + + mockMvc.perform(post("/devices/rsus/upgrade/check") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ip\": \"10.0.0.10\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.upgrade_available").value(true)) + .andExpect(jsonPath("$.upgrade_id").value(42)) + .andExpect(jsonPath("$.upgrade_name").value("RSU Firmware v2.0")) + .andExpect(jsonPath("$.upgrade_version").value("2.0")); + } + + @Test + void checkUpgrade_WithoutOrganizationHeader_ReturnsOk() throws Exception { + given(permissionService.isSuperUser()).willReturn(true); + given(rsuUpgradeService.checkFirmwareUpgrade(anyString())) + .willReturn(new FirmwareUpgradeCheckResponseDto(false, -1L, "", "")); + + mockMvc.perform(post("/devices/rsus/upgrade/check") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ip\": \"10.0.0.10\"}")) + .andExpect(status().isOk()); + } + + @Test + void checkUpgrade_BlankRsuIp_ReturnsBadRequest() throws Exception { + mockMvc.perform(post("/devices/rsus/upgrade/check") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ip\": \"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void checkUpgrade_Forbidden_ReturnsForbidden() throws Exception { + given(permissionService.isSuperUser()).willReturn(false); + given(permissionService.hasRsu(anyString(), anyString())).willReturn(false); + + mockMvc.perform(post("/devices/rsus/upgrade/check") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rsu_ip\": \"10.0.0.10\"}")) + .andExpect(status().isForbidden()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ActiveNotificationControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ActiveNotificationControllerTest.java index 939ef0577..b88584ed8 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ActiveNotificationControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ActiveNotificationControllerTest.java @@ -13,7 +13,6 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -23,10 +22,10 @@ import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.context.junit4.SpringRunner; import org.springframework.web.server.ResponseStatusException; -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import org.springframework.context.annotation.Import; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.LaneDirectionOfTravelNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.Notification; @@ -35,69 +34,66 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.broadcast_rate.MapBroadcastRateNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.broadcast_rate.SpatBroadcastRateNotification; import us.dot.its.jpo.ode.api.accessors.notifications.active_notification.ActiveNotificationRepository; +import us.dot.its.jpo.ode.api.models.UserRole; import us.dot.its.jpo.ode.api.services.PermissionService; import us.dot.its.jpo.ode.mockdata.MockNotificationGenerator; @SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) public class ActiveNotificationControllerTest { - private final ActiveNotificationController controller; - - @MockitoBean - ActiveNotificationRepository activeNotificationRepo; - - @MockitoBean - PermissionService permissionService; - - @Autowired - public ActiveNotificationControllerTest(ActiveNotificationController controller) { - this.controller = controller; - } - - @Test - public void testActiveNotification() { - List allowedInteresections = new ArrayList<>(); - allowedInteresections.add(null); - - when(permissionService.hasIntersection(null, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); - - SpatBroadcastRateNotification spatBroadcastRateNotification = MockNotificationGenerator - .getSpatBroadcastRateNotification(); - SignalStateConflictNotification signalStateConflictNotification = MockNotificationGenerator - .getSignalStateConflictNotification(); - SignalGroupAlignmentNotification signalGroupAlignmentNotification = MockNotificationGenerator - .getSignalGroupAlignmentNotification(); - MapBroadcastRateNotification mapBroadcastRateNotification = MockNotificationGenerator - .getMapBroadcastRateNotification(); - LaneDirectionOfTravelNotification laneDirectionOfTravelNotification = MockNotificationGenerator - .getLaneDirectionOfTravelNotification(); - ConnectionOfTravelNotification connectionOfTravelNotification = MockNotificationGenerator - .getConnectionOfTravelNotification(); - - List notifications = new ArrayList<>(); - notifications.add(spatBroadcastRateNotification); - notifications.add(signalStateConflictNotification); - notifications.add(signalGroupAlignmentNotification); - notifications.add(mapBroadcastRateNotification); - notifications.add(laneDirectionOfTravelNotification); - notifications.add(connectionOfTravelNotification); - - PageRequest page = PageRequest.of(1, 1); - when(activeNotificationRepo.find(null, - null, - null, PageRequest.of(1, 1))) - .thenReturn(new PageImpl<>(notifications, page, 1L)); - - ResponseEntity> result = controller - .findActiveNotifications( - null, null, null, 1, 1, false); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody().getContent()).isEqualTo(notifications); - } + private final ActiveNotificationController controller; + + @MockitoBean + ActiveNotificationRepository activeNotificationRepo; + + @MockitoBean + PermissionService permissionService; + + @Autowired + public ActiveNotificationControllerTest(ActiveNotificationController controller) { + this.controller = controller; + } + + @Test + public void testActiveNotification() { + when(permissionService.hasIntersection(null, "USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + + SpatBroadcastRateNotification spatBroadcastRateNotification = MockNotificationGenerator + .getSpatBroadcastRateNotification(); + SignalStateConflictNotification signalStateConflictNotification = MockNotificationGenerator + .getSignalStateConflictNotification(); + SignalGroupAlignmentNotification signalGroupAlignmentNotification = MockNotificationGenerator + .getSignalGroupAlignmentNotification(); + MapBroadcastRateNotification mapBroadcastRateNotification = MockNotificationGenerator + .getMapBroadcastRateNotification(); + LaneDirectionOfTravelNotification laneDirectionOfTravelNotification = MockNotificationGenerator + .getLaneDirectionOfTravelNotification(); + ConnectionOfTravelNotification connectionOfTravelNotification = MockNotificationGenerator + .getConnectionOfTravelNotification(); + + List notifications = new ArrayList<>(); + notifications.add(spatBroadcastRateNotification); + notifications.add(signalStateConflictNotification); + notifications.add(signalGroupAlignmentNotification); + notifications.add(mapBroadcastRateNotification); + notifications.add(laneDirectionOfTravelNotification); + notifications.add(connectionOfTravelNotification); + + PageRequest page = PageRequest.of(1, 1); + when(activeNotificationRepo.find(null, + null, + null, PageRequest.of(1, 1))) + .thenReturn(new PageImpl<>(notifications, page, 1L)); + + ResponseEntity> result = controller + .findActiveNotifications( + null, null, null, 1, 1, false); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().getContent()).isEqualTo(notifications); + } @Test void testFindActiveNotificationWithTestData() { @@ -107,7 +103,7 @@ void testFindActiveNotificationWithTestData() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); boolean testData = true; ResponseEntity> response = controller @@ -127,7 +123,7 @@ void testFindActiveNotificationsWithPagination() { events.add(event); when(permissionService.hasIntersection(event.getIntersectionID(), "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); Page mockPage = new PageImpl<>(events, PageRequest.of(0, 10), 1); when(activeNotificationRepo.find(eq(event.getIntersectionID()), any(), any(), @@ -151,7 +147,7 @@ public void testCountActiveNotificationsWithTestData() { boolean testData = true; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); ResponseEntity response = controller.countActiveNotifications(intersectionID, null, null, testData); @@ -168,7 +164,7 @@ public void testCountActiveNotifications() { Long expectedCount = 5L; when(permissionService.hasIntersection(intersectionID, "USER")).thenReturn(true); - when(permissionService.hasRole("USER")).thenReturn(true); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); when(activeNotificationRepo.count(intersectionID, notificationType, key)) .thenReturn(expectedCount); @@ -184,7 +180,7 @@ public void testCountActiveNotifications() { public void testDeleteActiveNotifications() { String key = "key"; - when(permissionService.hasRole("OPERATOR")).thenReturn(true); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); when(activeNotificationRepo.delete(key)) .thenReturn(1L); @@ -198,7 +194,7 @@ public void testDeleteActiveNotifications() { public void testDeleteActiveNotificationsNotFound() { String key = "key"; - when(permissionService.hasRole("OPERATOR")).thenReturn(true); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); when(activeNotificationRepo.delete(key)) .thenReturn(0L); @@ -213,7 +209,7 @@ public void testDeleteActiveNotificationsThrowsException() { String key = "key"; RuntimeException repoException = new RuntimeException("repo error"); - when(permissionService.hasRole("OPERATOR")).thenReturn(true); + when(permissionService.hasRole(UserRole.OPERATOR)).thenReturn(true); when(activeNotificationRepo.delete(key)).thenThrow(repoException); ResponseStatusException thrown = assertThrows( diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ConfigControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ConfigControllerTest.java index 9af21b528..0cf015a24 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ConfigControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/ConfigControllerTest.java @@ -38,7 +38,6 @@ import us.dot.its.jpo.ode.api.accessors.config.default_config.DefaultConfigRepository; import us.dot.its.jpo.ode.api.accessors.config.intersection_config.IntersectionConfigRepository; import us.dot.its.jpo.ode.api.services.PermissionService; -import us.dot.its.jpo.ode.api.services.PostgresService; public class ConfigControllerTest { @@ -53,9 +52,6 @@ public class ConfigControllerTest { @Mock ConflictMonitorApiProperties props; - @Mock - PostgresService postgresService; - @Mock PermissionService permissionService; @@ -69,7 +65,6 @@ void setUp() { defaultConfigRepository, intersectionConfigRepository, props, - postgresService, permissionService); ReflectionTestUtils.setField(controller, "restTemplate", restTemplate); @@ -319,7 +314,7 @@ void testIntersectionConfigAllSuccessByUser() { String resourceURL = "http://localhost/config/intersections"; when(restTemplate.getForEntity(resourceURL, IntersectionConfigMap.class)) .thenReturn(new ResponseEntity<>(configMap, HttpStatus.OK)); - when(postgresService.getAllowedIntersectionIdsByEmail(eq(username))) + when(permissionService.getAllowedIntersectionIdsByEmail(eq(username))) .thenReturn(Collections.singletonList(1)); try (MockedStatic mockedStatic = Mockito.mockStatic(PermissionService.class)) { @@ -344,7 +339,7 @@ void testIntersectionConfigAllSuccessByOrg() { String resourceURL = "http://localhost/config/intersections"; when(restTemplate.getForEntity(resourceURL, IntersectionConfigMap.class)) .thenReturn(new ResponseEntity<>(configMap, HttpStatus.OK)); - when(postgresService.getAllowedIntersectionIdsByOrganization(eq("org"))) + when(permissionService.getAllowedIntersectionIdsByOrganization(eq("org"))) .thenReturn(Collections.singletonList(1)); ResponseEntity>> response = controller.intersection_config_all("org"); diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/IntersectionControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/IntersectionControllerTest.java index a70452362..63df3ec8b 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/IntersectionControllerTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/IntersectionControllerTest.java @@ -20,7 +20,6 @@ import us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository; import us.dot.its.jpo.ode.api.models.IntersectionReferenceData; import us.dot.its.jpo.ode.api.services.PermissionService; -import us.dot.its.jpo.ode.api.services.PostgresService; public class IntersectionControllerTest { @@ -30,14 +29,14 @@ public class IntersectionControllerTest { ProcessedMapRepository processedMapRepo; @Mock - PostgresService postgresService; + PermissionService permissionService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); controller = new IntersectionController( processedMapRepo, - postgresService); + permissionService); } @Test @@ -55,7 +54,7 @@ void testGetIntersectionsByOrganization() { List allIntersections = Collections.singletonList(intersection); when(processedMapRepo.getIntersectionIDs()).thenReturn(allIntersections); - when(postgresService.getAllowedIntersectionIdsByOrganization("org")) + when(permissionService.getAllowedIntersectionIdsByOrganization("org")) .thenReturn(Collections.singletonList(1)); ResponseEntity> response = controller.getIntersections("org", false); @@ -74,7 +73,7 @@ void testGetIntersectionsByEmail() { String username = "testuser"; try (MockedStatic mockedStatic = Mockito.mockStatic(PermissionService.class)) { mockedStatic.when(() -> PermissionService.getUsername(any())).thenReturn(username); - when(postgresService.getAllowedIntersectionIdsByEmail(username)) + when(permissionService.getAllowedIntersectionIdsByEmail(username)) .thenReturn(Collections.singletonList(2)); ResponseEntity> response = controller.getIntersections(null, false); @@ -100,7 +99,7 @@ void testGetIntersectionsByLocationByOrganization() { List allIntersections = Collections.singletonList(intersection); when(processedMapRepo.getIntersectionsContainingPoint(1.0, 2.0)).thenReturn(allIntersections); - when(postgresService.getAllowedIntersectionIdsByOrganization("org")) + when(permissionService.getAllowedIntersectionIdsByOrganization("org")) .thenReturn(Collections.singletonList(3)); ResponseEntity> response = controller.getIntersectionsByLocation("org", 1.0, @@ -120,7 +119,7 @@ void testGetIntersectionsByLocationByEmail() { String username = "testuser2"; try (MockedStatic mockedStatic = Mockito.mockStatic(PermissionService.class)) { mockedStatic.when(() -> PermissionService.getUsername(any())).thenReturn(username); - when(postgresService.getAllowedIntersectionIdsByEmail(username)) + when(permissionService.getAllowedIntersectionIdsByEmail(username)) .thenReturn(Collections.singletonList(4)); ResponseEntity> response = controller.getIntersectionsByLocation(null, 5.0, diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/SnmpController.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/SnmpController.java deleted file mode 100644 index b782ae1c3..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/intersections/SnmpController.java +++ /dev/null @@ -1,405 +0,0 @@ -// package us.dot.its.jpo.ode.api.controllers.intersections; -// import java.util.ArrayList; -// import java.util.List; -// import java.util.stream.Collectors; - -// import lombok.extern.slf4j.Slf4j; - -// import org.springframework.beans.factory.annotation.Autowired; -// import -// org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -// import org.springframework.data.mongodb.core.query.Query; -// import org.springframework.http.HttpEntity; -// import org.springframework.http.HttpHeaders; -// import org.springframework.http.HttpStatus; -// import org.springframework.http.ResponseEntity; -// import org.springframework.security.access.prepost.PreAuthorize; -// import org.springframework.security.core.Authentication; -// import org.springframework.security.core.context.SecurityContextHolder; -// import org.springframework.web.bind.annotation.DeleteMapping; -// import org.springframework.web.bind.annotation.PostMapping; -// import org.springframework.web.bind.annotation.RequestMapping; -// import org.springframework.web.bind.annotation.RequestMethod; -// import org.springframework.web.bind.annotation.RequestParam; -// import org.springframework.web.bind.annotation.ResponseBody; -// import org.springframework.web.bind.annotation.RestController; -// import org.springframework.web.client.RestTemplate; -// import org.springframework.web.server.ResponseStatusException; - -// import io.swagger.v3.oas.annotations.Operation; -// import io.swagger.v3.oas.annotations.responses.ApiResponse; -// import io.swagger.v3.oas.annotations.responses.ApiResponses; - -// import org.springframework.web.bind.annotation.RequestBody; -// import org.springframework.web.bind.annotation.RequestHeader; - -// import us.dot.its.jpo.conflictmonitor.monitor.models.config.Config; -// import us.dot.its.jpo.conflictmonitor.monitor.models.config.DefaultConfig; -// import us.dot.its.jpo.conflictmonitor.monitor.models.config.DefaultConfigMap; -// import -// us.dot.its.jpo.conflictmonitor.monitor.models.config.IntersectionConfig; -// import -// us.dot.its.jpo.conflictmonitor.monitor.models.config.IntersectionConfigMap; -// import us.dot.its.jpo.ode.api.ConflictMonitorApiProperties; -// import -// us.dot.its.jpo.ode.api.accessors.config.default_config.DefaultConfigRepository; -// import -// us.dot.its.jpo.ode.api.accessors.config.intersection_config.IntersectionConfigRepository; -// import us.dot.its.jpo.ode.api.services.PermissionService; -// import us.dot.its.jpo.ode.api.services.PostgresService; - -// import org.springframework.http.MediaType; - -// @Slf4j -// @RestController -// @ConditionalOnProperty(name = "enable.api", havingValue = "true", -// matchIfMissing = false) -// @ApiResponses(value = { -// @ApiResponse(responseCode = "400", description = "Bad Request - Request body -// did not match expected format"), -// @ApiResponse(responseCode = "401", description = "Unauthorized"), -// @ApiResponse(responseCode = "500", description = "Internal Server Error") -// }) -// @RequestMapping("/snmp/permissions") -// public class SnmpController { -// private final ConflictMonitorApiProperties props; -// private final PostgresService postgresService; -// private final PermissionService permissionService; - -// private final RestTemplate restTemplate = new RestTemplate(); - -// private final String defaultConfigTemplate = "%s/config/default/%s"; -// private final String intersectionConfigTemplate = -// "%s/config/intersection/%s/%s"; -// private final String defaultConfigAllTemplate = "%s/config/defaults"; -// private final String intersectionConfigAllTemplate = -// "%s/config/intersections"; - -// @Autowired -// public ConfigController( -// DefaultConfigRepository defaultConfigRepository, -// IntersectionConfigRepository intersectionConfigRepository, -// ConflictMonitorApiProperties props, -// PostgresService postgresService, -// PermissionService permissionService) { -// this.defaultConfigRepository = defaultConfigRepository; -// this.intersectionConfigRepository = intersectionConfigRepository; -// this.props = props; -// this.postgresService = postgresService; -// this.permissionService = permissionService; -// } - -// @Operation(summary = "Set Default Config", description = "Set a default -// configuration parameter, this will change this parameter on all -// non-overridden intersections. Requires SUPER_USER permissions.") -// @PostMapping(value = "/default", produces = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser()") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER"), -// @ApiResponse(responseCode = "404", description = "Configuration setting not -// found"), -// }) -// public @ResponseBody ResponseEntity> -// default_config(@RequestBody DefaultConfig config) { -// try { -// String resourceURL = String.format(defaultConfigTemplate, -// props.getCmServerURL(), config.getKey()); - -// // Request does not require authentication, ConflictMonitor API is only -// // accessible internally -// @SuppressWarnings("rawtypes") -// ResponseEntity response = -// restTemplate.getForEntity(resourceURL, DefaultConfig.class); - -// if (response.getStatusCode().is2xxSuccessful()) { -// DefaultConfig previousConfig = response.getBody(); -// previousConfig.setValue(config.getValue()); - -// HttpHeaders headers = new HttpHeaders(); -// headers.setContentType(MediaType.APPLICATION_JSON); -// HttpEntity> requestEntity = new HttpEntity<>(previousConfig, -// headers); - -// restTemplate.postForEntity(resourceURL, requestEntity, DefaultConfig.class); -// defaultConfigRepository.save(previousConfig); -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON) -// .body(previousConfig); -// } else { -// throw new ResponseStatusException(response.getStatusCode(), -// "Conflict Monitor API was unable to change setting on conflict monitor."); -// } -// } catch (Exception e) { -// log.error("Failed to set default config param", e); -// throw new ResponseStatusException(HttpStatus.BAD_REQUEST, -// "Unable to identify Message Type from ASN.1"); -// } -// } - -// @Operation(summary = "Create or Modify Intersection Config Parameter -// Overrides", description = "Create or modify an overridden intersection -// parameter. Requires SUPER_USER or OPERATOR permissions") -// @PostMapping(value = "/intersection", produces = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser() || -// @PermissionService.hasRole('OPERATOR')") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER, or OPERATOR role with access to the intersection requested"), -// @ApiResponse(responseCode = "404", description = "Configuration setting not -// found to modify/override"), -// }) -// public @ResponseBody ResponseEntity> -// intersection_config( -// @RequestBody IntersectionConfig config) { -// if (!permissionService.hasIntersection(config.getIntersectionID(), -// "OPERATOR")) { -// throw new ResponseStatusException(HttpStatus.FORBIDDEN, -// "User does not have permission to delete intersection configuration -// parameters"); -// } -// try { -// String resourceURL = String.format(intersectionConfigTemplate, -// props.getCmServerURL(), -// config.getIntersectionID(), config.getKey()); -// @SuppressWarnings("rawtypes") -// ResponseEntity response = -// restTemplate.getForEntity(resourceURL, -// IntersectionConfig.class); - -// if (response.getStatusCode().is2xxSuccessful()) { -// IntersectionConfig previousConfig = response.getBody(); - -// if (previousConfig == null) { -// previousConfig = config; -// } else { -// previousConfig.setValue(config.getValue()); -// } - -// HttpHeaders headers = new HttpHeaders(); -// headers.setContentType(MediaType.APPLICATION_JSON); -// HttpEntity> requestEntity = new -// HttpEntity<>(previousConfig, headers); - -// restTemplate.postForEntity(resourceURL, requestEntity, -// IntersectionConfig.class); - -// intersectionConfigRepository.save(previousConfig); -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON) -// .body(previousConfig); -// } else { -// log.error("Failed error code returned from ConflictMonitor API: {}, with -// response: {}", -// response.getStatusCode(), response.getBody().toString()); -// throw new ResponseStatusException(response.getStatusCode(), -// "Conflict Monitor API was unable to change setting on conflict monitor"); -// } -// } catch (Exception e) { -// log.error("Failed to set intersection config param", e); -// throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, -// String.format( -// "Exception updating intersection configuration parameter: %s", -// e.getMessage()), -// e); -// } -// } - -// @Operation(summary = "Delete Intersection Config Parameter", description = -// "Delete an intersection parameter override. Requires SUPER_USER or OPERATOR -// permissions.") -// @DeleteMapping(value = "/intersection", produces = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser() || -// @PermissionService.hasRole('OPERATOR')") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER, or OPERATOR role with access to the intersection requested"), -// @ApiResponse(responseCode = "404", description = "Configuration setting -// override not found to delete"), -// }) -// public @ResponseBody ResponseEntity -// intersection_config_delete(@RequestBody IntersectionConfig config) { -// if (!permissionService.hasIntersection(config.getIntersectionID(), -// "OPERATOR")) { -// throw new ResponseStatusException(HttpStatus.FORBIDDEN, -// "User does not have permission to delete intersection configuration -// parameters"); -// } - -// Query query = intersectionConfigRepository.getQuery(config.getKey(), -// config.getIntersectionID()); - -// try { -// String resourceURL = String.format(intersectionConfigTemplate, -// props.getCmServerURL(), -// config.getIntersectionID(), config.getKey()); -// restTemplate.delete(resourceURL); -// intersectionConfigRepository.delete(query); -// return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); -// } catch (Exception e) { -// throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, -// String.format( -// "Exception deleting intersection configuration parameter: %s", -// e.getMessage()), -// e); -// } -// } - -// @Operation(summary = "Retrieve All Default Config Parameters", description = -// "Retrieve all default configuration parameters") -// @RequestMapping(value = "/default/all", method = RequestMethod.GET, produces -// = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser() || -// @PermissionService.hasRole('USER')") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER, or USER role"), -// }) -// public @ResponseBody ResponseEntity>> -// default_config_all() { - -// String resourceURL = String.format(defaultConfigAllTemplate, -// props.getCmServerURL()); -// ResponseEntity response = -// restTemplate.getForEntity(resourceURL, DefaultConfigMap.class); - -// if (response.getStatusCode().is2xxSuccessful()) { -// DefaultConfigMap configMap = response.getBody(); -// List> results = new ArrayList<>(configMap.values()); -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON).body(results); -// } else { -// throw new ResponseStatusException(response.getStatusCode(), -// String.format("The ConflictMonitor API was unable to retrieve default -// configuration parameters: ", -// response.getBody())); -// } -// } - -// @Operation(summary = "Retrieve All Overridden Intersection Config -// Parameters", description = "Retrieve all overridden intersection -// configuration parameters") -// @RequestMapping(value = "/intersection/all", method = RequestMethod.GET, -// produces = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser() || -// @PermissionService.hasRole('USER')") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER, or USER role"), -// }) -// public @ResponseBody ResponseEntity>> -// intersection_config_all( -// @RequestHeader(name = "Organization", required = false) String organization) -// { - -// String resourceURL = String.format(intersectionConfigAllTemplate, -// props.getCmServerURL()); -// ResponseEntity response = -// restTemplate.getForEntity(resourceURL, -// IntersectionConfigMap.class); -// if (response.getStatusCode().is2xxSuccessful()) { -// IntersectionConfigMap configMap = response.getBody(); -// ArrayList> results = new -// ArrayList<>(configMap.listConfigs()); - -// if (organization == null) { -// Authentication auth = SecurityContextHolder.getContext().getAuthentication(); -// String username = PermissionService.getUsername(auth); -// List allowedIntersectionIds = -// postgresService.getAllowedIntersectionIdsByEmail(username); -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON).body(results -// .stream() -// .filter(intersection -> -// allowedIntersectionIds.contains(intersection.getIntersectionID())) -// .collect(Collectors.toList())); -// } else { -// List allowedIntersectionIds = postgresService -// .getAllowedIntersectionIdsByOrganization(organization); -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON).body(results -// .stream() -// .filter(intersection -> -// allowedIntersectionIds.contains(intersection.getIntersectionID())) -// .collect(Collectors.toList())); -// } -// } else { -// throw new ResponseStatusException(response.getStatusCode(), -// String.format( -// "The ConflictMonitor API was unable to retrieve overridden configuration -// parameters: ", -// response.getBody())); -// } -// } - -// @Operation(summary = "Retrieve All Unique Intersection Config Parameters", -// description = "Retrieve all intersection configuration parameters, showing -// defaults where no override exists, otherwise showing the overridden -// parameter") -// @RequestMapping(value = "/intersection/unique", method = RequestMethod.GET, -// produces = "application/json") -// @PreAuthorize("@PermissionService.isSuperUser() || -// (@PermissionService.hasIntersection(#intersectionID, 'USER') and -// @PermissionService.hasRole('USER'))") -// @ApiResponses(value = { -// @ApiResponse(responseCode = "200", description = "Success"), -// @ApiResponse(responseCode = "403", description = "Forbidden - Requires -// SUPER_USER, or USER role"), -// }) -// public @ResponseBody ResponseEntity>> -// intersection_config_unique( -// @RequestParam(name = "intersection_id", required = true) int intersectionID) -// { - -// // Query Default Configuration -// String defaultResourceURL = String.format(defaultConfigAllTemplate, -// props.getCmServerURL()); -// List> defaultList = new ArrayList<>(); -// ResponseEntity defaultConfigResponse = -// restTemplate.getForEntity(defaultResourceURL, -// DefaultConfigMap.class); -// if (defaultConfigResponse.getStatusCode().is2xxSuccessful()) { -// DefaultConfigMap configMap = defaultConfigResponse.getBody(); -// defaultList = new ArrayList<>(configMap.values()); -// } - -// // Query Intersection Configuration -// List> intersectionList = new ArrayList<>(); -// String intersectionResourceURL = String.format(intersectionConfigAllTemplate, -// props.getCmServerURL()); -// ResponseEntity intersectionConfigResponse = -// restTemplate -// .getForEntity(intersectionResourceURL, IntersectionConfigMap.class); -// if (intersectionConfigResponse.getStatusCode().is2xxSuccessful()) { -// IntersectionConfigMap configMap = intersectionConfigResponse.getBody(); -// ArrayList> results = new -// ArrayList<>(configMap.listConfigs()); - -// for (IntersectionConfig config : results) { -// if (config.getIntersectionID() == intersectionID) { -// intersectionList.add(config); -// } -// } -// } - -// List> finalConfig = new ArrayList<>(); -// for (DefaultConfig defaultConfig : defaultList) { -// Config addConfig = defaultConfig; -// for (IntersectionConfig intersectionConfig : intersectionList) { -// if (intersectionConfig.getKey().equals(defaultConfig.getKey())) { -// addConfig = intersectionConfig; -// break; -// } -// } -// finalConfig.add(addConfig); -// } - -// return -// ResponseEntity.status(HttpStatus.OK).contentType(MediaType.APPLICATION_JSON).body(finalConfig); -// } -// } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/organizations/OrganizationControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/organizations/OrganizationControllerTest.java new file mode 100644 index 000000000..e6ba66a08 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/organizations/OrganizationControllerTest.java @@ -0,0 +1,662 @@ +package us.dot.its.jpo.ode.api.controllers.organizations; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import us.dot.its.jpo.ode.api.repositories.RsuOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; +import us.dot.its.jpo.ode.api.repositories.UserOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.UserRepository; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OrganizationControllerTest { + + @Mock + private RsuRepository rsuRepository; + + @Mock + private RsuOrganizationRepository rsuOrganizationRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserOrganizationRepository userOrganizationRepository; + + @InjectMocks + private OrganizationController organizationController; + + // ==================== GET RSU IPS BY ORGANIZATION TESTS ==================== + + @Test + void testGetRsuIpsByOrganization_Success() throws UnknownHostException { + // Arrange + String organization = "TestOrg"; + + List mockIpAddresses = Arrays.asList( + InetAddress.getByName("192.168.1.100"), + InetAddress.getByName("192.168.1.101"), + InetAddress.getByName("192.168.1.102")); + + when(rsuOrganizationRepository.findAllRsuIpsByOrganizationName(organization)) + .thenReturn(mockIpAddresses); + + // Act + List result = organizationController.getRsuIpsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("192.168.1.100", result.get(0)); + assertEquals("192.168.1.101", result.get(1)); + assertEquals("192.168.1.102", result.get(2)); + + verify(rsuOrganizationRepository).findAllRsuIpsByOrganizationName(organization); + } + + @Test + void testGetRsuIpsByOrganization_EmptyResult() { + // Arrange + String organization = "EmptyOrg"; + + when(rsuOrganizationRepository.findAllRsuIpsByOrganizationName(organization)) + .thenReturn(Arrays.asList()); + + // Act + List result = organizationController.getRsuIpsByOrganization(organization); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + verify(rsuOrganizationRepository).findAllRsuIpsByOrganizationName(organization); + } + + @Test + void testGetRsuIpsByOrganization_SingleRsu() throws UnknownHostException { + // Arrange + String organization = "SingleRsuOrg"; + + List mockIpAddresses = Arrays.asList( + InetAddress.getByName("192.168.1.100")); + + when(rsuOrganizationRepository.findAllRsuIpsByOrganizationName(organization)) + .thenReturn(mockIpAddresses); + + // Act + List result = organizationController.getRsuIpsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("192.168.1.100", result.get(0)); + + verify(rsuOrganizationRepository).findAllRsuIpsByOrganizationName(organization); + } + + @Test + void testGetRsuIpsByOrganization_MultiplePages() throws UnknownHostException { + // Arrange + String organization = "LargeOrg"; + + List mockIpAddresses = Arrays.asList( + InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("192.168.1.2"), + InetAddress.getByName("192.168.1.3"), + InetAddress.getByName("192.168.1.4"), + InetAddress.getByName("192.168.1.5")); + + when(rsuOrganizationRepository.findAllRsuIpsByOrganizationName(organization)) + .thenReturn(mockIpAddresses); + + // Act + List result = organizationController.getRsuIpsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(5, result.size()); + + verify(rsuOrganizationRepository).findAllRsuIpsByOrganizationName(organization); + } + + @Test + void testGetRsuIpsByOrganization_IpAddressFormatting() throws UnknownHostException { + // Arrange + String organization = "TestOrg"; + + List mockIpAddresses = Arrays.asList( + InetAddress.getByName("10.0.0.1"), + InetAddress.getByName("172.16.0.1"), + InetAddress.getByName("192.168.0.1")); + + when(rsuOrganizationRepository.findAllRsuIpsByOrganizationName(organization)) + .thenReturn(mockIpAddresses); + + // Act + List result = organizationController.getRsuIpsByOrganization(organization); + + // Assert + assertEquals("10.0.0.1", result.get(0)); + assertEquals("172.16.0.1", result.get(1)); + assertEquals("192.168.0.1", result.get(2)); + } + + // ==================== GET RSU ORGANIZATION ASSIGNMENTS TESTS + // ==================== + + @Test + void testGetRsuOrganizationAssignments_Success() throws UnknownHostException { + // Arrange + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + List mockOrganizations = Arrays.asList("Org1", "Org2", "Org3"); + + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getRsuOrganizationAssignments(rsuIp); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("Org1", result.get(0)); + assertEquals("Org2", result.get(1)); + assertEquals("Org3", result.get(2)); + + verify(rsuRepository).findAllOrganizationNamesByIpv4Address(inetAddress); + } + + @Test + void testGetRsuOrganizationAssignments_SingleOrganization() throws UnknownHostException { + // Arrange + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + List mockOrganizations = Arrays.asList("OnlyOrg"); + + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getRsuOrganizationAssignments(rsuIp); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("OnlyOrg", result.get(0)); + + verify(rsuRepository).findAllOrganizationNamesByIpv4Address(inetAddress); + } + + @Test + void testGetRsuOrganizationAssignments_NoOrganizations() throws UnknownHostException { + // Arrange + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenReturn(Arrays.asList()); + + // Act + List result = organizationController.getRsuOrganizationAssignments(rsuIp); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + verify(rsuRepository).findAllOrganizationNamesByIpv4Address(inetAddress); + } + + @Test + void testGetRsuOrganizationAssignments_InvalidIpAddress() { + // Arrange + String invalidIp = "invalid-ip-address"; + + // Act & Assert + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> organizationController.getRsuOrganizationAssignments(invalidIp)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Invalid RSU IP address")); + assertTrue(exception.getReason().contains(invalidIp)); + + verify(rsuRepository, never()).findAllOrganizationNamesByIpv4Address(any()); + } + + @Test + void testGetRsuOrganizationAssignments_MalformedIpAddress() { + // Arrange + String malformedIp = "999.999.999.999"; + + // Act & Assert + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> organizationController.getRsuOrganizationAssignments(malformedIp)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Invalid RSU IP address")); + + verify(rsuRepository, never()).findAllOrganizationNamesByIpv4Address(any()); + } + + @Test + void testGetRsuOrganizationAssignments_IpWithHostname() { + // Arrange + String hostnameIp = "localhost"; + + // Act & Assert - This should work as InetAddress.getByName() can resolve + // hostnames + assertDoesNotThrow(() -> { + organizationController.getRsuOrganizationAssignments(hostnameIp); + }); + } + + @Test + void testGetRsuOrganizationAssignments_IPv6Address() throws UnknownHostException { + // Arrange + String ipv6Address = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + InetAddress inetAddress = InetAddress.getByName(ipv6Address); + + List mockOrganizations = Arrays.asList("Org1"); + + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getRsuOrganizationAssignments(ipv6Address); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + + verify(rsuRepository).findAllOrganizationNamesByIpv4Address(inetAddress); + } + + @Test + void testGetRsuOrganizationAssignments_DifferentIpRanges() throws UnknownHostException { + // Arrange + String[] testIps = { + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "8.8.8.8" + }; + + for (String ip : testIps) { + InetAddress inetAddress = InetAddress.getByName(ip); + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenReturn(Arrays.asList("TestOrg")); + + // Act + List result = organizationController.getRsuOrganizationAssignments(ip); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("TestOrg", result.get(0)); + } + + verify(rsuRepository, times(4)).findAllOrganizationNamesByIpv4Address(any()); + } + + @Test + void testGetRsuOrganizationAssignments_RepositoryThrowsException() throws UnknownHostException { + // Arrange + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + when(rsuRepository.findAllOrganizationNamesByIpv4Address(inetAddress)) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + assertThrows(RuntimeException.class, + () -> organizationController.getRsuOrganizationAssignments(rsuIp)); + + verify(rsuRepository).findAllOrganizationNamesByIpv4Address(inetAddress); + } + + // ==================== GET USER EMAILS BY ORGANIZATION TESTS + // ==================== + + @Test + void testGetUserEmailsByOrganization_Success() { + // Arrange + String organization = "TestOrg"; + List mockEmails = Arrays.asList( + "user1@example.com", + "user2@example.com", + "user3@example.com"); + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenReturn(mockEmails); + + // Act + List result = organizationController.getUserEmailsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("user1@example.com", result.get(0)); + assertEquals("user2@example.com", result.get(1)); + assertEquals("user3@example.com", result.get(2)); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + @Test + void testGetUserEmailsByOrganization_EmptyResult() { + // Arrange + String organization = "EmptyOrg"; + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenReturn(Arrays.asList()); + + // Act + List result = organizationController.getUserEmailsByOrganization(organization); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + @Test + void testGetUserEmailsByOrganization_SingleUser() { + // Arrange + String organization = "SingleUserOrg"; + List mockEmails = Arrays.asList("onlyuser@example.com"); + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenReturn(mockEmails); + + // Act + List result = organizationController.getUserEmailsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("onlyuser@example.com", result.get(0)); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + @Test + void testGetUserEmailsByOrganization_MultipleUsers() { + // Arrange + String organization = "LargeOrg"; + List mockEmails = Arrays.asList( + "admin@example.com", + "operator@example.com", + "user1@example.com", + "user2@example.com", + "user3@example.com", + "guest@example.com"); + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenReturn(mockEmails); + + // Act + List result = organizationController.getUserEmailsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(6, result.size()); + assertTrue(result.contains("admin@example.com")); + assertTrue(result.contains("operator@example.com")); + assertTrue(result.contains("guest@example.com")); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + @Test + void testGetUserEmailsByOrganization_DifferentEmailFormats() { + // Arrange + String organization = "TestOrg"; + List mockEmails = Arrays.asList( + "simple@example.com", + "first.last@example.com", + "user+tag@example.com", + "user_123@subdomain.example.com"); + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenReturn(mockEmails); + + // Act + List result = organizationController.getUserEmailsByOrganization(organization); + + // Assert + assertNotNull(result); + assertEquals(4, result.size()); + assertEquals("simple@example.com", result.get(0)); + assertEquals("first.last@example.com", result.get(1)); + assertEquals("user+tag@example.com", result.get(2)); + assertEquals("user_123@subdomain.example.com", result.get(3)); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + @Test + void testGetUserEmailsByOrganization_RepositoryThrowsException() { + // Arrange + String organization = "TestOrg"; + + when(userOrganizationRepository.findAllUserEmailsByOrganizationName(organization)) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + assertThrows(RuntimeException.class, + () -> organizationController.getUserEmailsByOrganization(organization)); + + verify(userOrganizationRepository).findAllUserEmailsByOrganizationName(organization); + } + + // ==================== GET USER ORGANIZATION ASSIGNMENTS TESTS + // ==================== + + @Test + void testGetUserOrganizationAssignments_Success() { + // Arrange + String email = "user@example.com"; + List mockOrganizations = Arrays.asList("Org1", "Org2", "Org3"); + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("Org1", result.get(0)); + assertEquals("Org2", result.get(1)); + assertEquals("Org3", result.get(2)); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_SingleOrganization() { + // Arrange + String email = "user@example.com"; + List mockOrganizations = Arrays.asList("OnlyOrg"); + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("OnlyOrg", result.get(0)); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_NoOrganizations() { + // Arrange + String email = "user@example.com"; + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(Arrays.asList()); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_MultipleOrganizations() { + // Arrange + String email = "admin@example.com"; + List mockOrganizations = Arrays.asList( + "CDOT", + "WYDOT", + "TestOrg", + "AnotherOrg", + "GlobalOrg"); + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertEquals(5, result.size()); + assertTrue(result.contains("CDOT")); + assertTrue(result.contains("WYDOT")); + assertTrue(result.contains("GlobalOrg")); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_DifferentEmailFormats() { + // Arrange + String[] testEmails = { + "simple@example.com", + "first.last@example.com", + "user+tag@example.com", + "user_123@subdomain.example.com" + }; + + for (String email : testEmails) { + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(Arrays.asList("TestOrg")); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("TestOrg", result.get(0)); + } + + verify(userRepository, times(4)).findAllOrganizationNamesByEmail(anyString()); + } + + @Test + void testGetUserOrganizationAssignments_CaseSensitiveEmail() { + // Arrange + String email1 = "User@Example.com"; + String email2 = "user@example.com"; + + when(userRepository.findAllOrganizationNamesByEmail(email1)) + .thenReturn(Arrays.asList("Org1")); + when(userRepository.findAllOrganizationNamesByEmail(email2)) + .thenReturn(Arrays.asList("Org2")); + + // Act + List result1 = organizationController.getUserOrganizationAssignments(email1); + List result2 = organizationController.getUserOrganizationAssignments(email2); + + // Assert + assertNotNull(result1); + assertNotNull(result2); + // Verify that the repository is called with the exact case provided + verify(userRepository).findAllOrganizationNamesByEmail(email1); + verify(userRepository).findAllOrganizationNamesByEmail(email2); + } + + @Test + void testGetUserOrganizationAssignments_SpecialCharactersInEmail() { + // Arrange + String email = "user+special@example.com"; + List mockOrganizations = Arrays.asList("TestOrg"); + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getUserOrganizationAssignments(email); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_RepositoryThrowsException() { + // Arrange + String email = "user@example.com"; + + when(userRepository.findAllOrganizationNamesByEmail(email)) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + assertThrows(RuntimeException.class, + () -> organizationController.getUserOrganizationAssignments(email)); + + verify(userRepository).findAllOrganizationNamesByEmail(email); + } + + @Test + void testGetUserOrganizationAssignments_LongEmailAddress() { + // Arrange + String longEmail = "verylongemailaddress.with.many.dots.and.characters@subdomain.example.com"; + List mockOrganizations = Arrays.asList("TestOrg"); + + when(userRepository.findAllOrganizationNamesByEmail(longEmail)) + .thenReturn(mockOrganizations); + + // Act + List result = organizationController.getUserOrganizationAssignments(longEmail); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + + verify(userRepository).findAllOrganizationNamesByEmail(longEmail); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/scms/ScmsHealthControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/scms/ScmsHealthControllerTest.java new file mode 100644 index 000000000..93091daa9 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/scms/ScmsHealthControllerTest.java @@ -0,0 +1,146 @@ +package us.dot.its.jpo.ode.api.controllers.scms; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +import java.net.InetAddress; +import java.time.Instant; +import java.util.List; + +import us.dot.its.jpo.ode.api.models.postgres.projections.ScmsHealthRsuProjection; +import us.dot.its.jpo.ode.api.models.postgres.projections.ScmsHealthRsuProjectionImpl; +import us.dot.its.jpo.ode.api.services.PermissionService; +import us.dot.its.jpo.ode.api.services.ScmsHealthService; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class ScmsHealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ScmsHealthService scmsHealthService; + + @MockitoBean + private PermissionService permissionService; + + @Test + @DisplayName("Retrieved successfully") + void testGetScmsStatus_SUCCESS() throws Exception { + // Arrange + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl( + InetAddress.getByName("10.0.0.1"), true, Instant.now()); + List queryResults = List.of(projection); + + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRoleInOrg("TestOrg", "USER")).thenReturn(true); + when(scmsHealthService.getScmsStatuses(anyString())).thenReturn(queryResults); + + // Act & Assert + mockMvc.perform(get("/devices/scms/status") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scmsHealthByIp['10.0.0.1'].health").value(true)); + + verify(scmsHealthService).getScmsStatuses(anyString()); + } + + @Test + @DisplayName("Retrieval fails when Organization header is missing") + void testGetScmsStatus_FAILURE_OrganizationHeaderMissing() throws Exception { + // Act & Assert + mockMvc.perform(get("/devices/scms/status")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Retrieval succeeds when no results are returned") + void testGetScmsStatus_SUCCESS_EmptyResults() throws Exception { + // Arrange + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRoleInOrg("TestOrg", "USER")).thenReturn(true); + when(scmsHealthService.getScmsStatuses(anyString())).thenReturn(List.of()); + + // Act & Assert + mockMvc.perform(get("/devices/scms/status") + .header("Organization", "TestOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scmsHealthByIp").isMap()) + .andExpect(jsonPath("$.scmsHealthByIp.*").isEmpty()); + + verify(scmsHealthService).getScmsStatuses(anyString()); + } + + @Test + @DisplayName("Retrieval fails when Organization is not found") + void testGetScmsStatus_FAILURE_OrganizationNotFound() throws Exception { + // Arrange + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRoleInOrg("TestOrg", "USER")).thenReturn(true); + when(scmsHealthService.getScmsStatuses(anyString())).thenThrow(new EntityNotFoundException("Organization not found")); + + // Act & Assert + mockMvc.perform(get("/devices/scms/status") + .header("Organization", "TestOrg")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Retrieval succeeds when user is a super user") + void testGetScmsStatus_SUCCESS_AsSuperUser() throws Exception { + // Arrange - super user can access any organization + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl( + InetAddress.getByName("10.0.0.1"), true, Instant.now()); + List queryResults = List.of(projection); + + when(permissionService.isSuperUser()).thenReturn(true); + when(scmsHealthService.getScmsStatuses(anyString())).thenReturn(queryResults); + + // Act & Assert + mockMvc.perform(get("/devices/scms/status") + .header("Organization", "AnyOrg")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scmsHealthByIp['10.0.0.1'].health").value(true)); + + verify(scmsHealthService).getScmsStatuses(anyString()); + // hasRoleInOrg should NOT be called since isSuperUser returns true + verify(permissionService, never()).hasRoleInOrg(anyString(), eq("USER")); + } + + @Test + @DisplayName("Retrieval fails when user is not authorized to access the requested organization") + void testGetScmsStatus_FORBIDDEN_UserNotInOrganization() throws Exception { + // Arrange - user does not have access to the requested organization + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRoleInOrg("UnauthorizedOrg", "USER")).thenReturn(false); + + // Act & Assert + mockMvc.perform(get("/devices/scms/status") + .header("Organization", "UnauthorizedOrg")) + .andExpect(status().isForbidden()); + + // Service should NOT be called since authorization failed + verify(scmsHealthService, never()).getScmsStatuses(anyString()); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/SubscriptionControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/SubscriptionControllerTest.java new file mode 100644 index 000000000..fe06ba14a --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/SubscriptionControllerTest.java @@ -0,0 +1,219 @@ +package us.dot.its.jpo.ode.api.controllers.users; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.emails.UserEmailNotificationDto; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.services.EmailService; +import us.dot.its.jpo.ode.api.services.PermissionService; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +public class SubscriptionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private EmailService emailService; + + @MockitoBean + private PermissionService permissionService; + + private CvManagerAuthToken authToken; + + private static final String email = "user@example.com"; + + private static final List validSubscriptionsList = Arrays.asList( + new UserEmailNotificationDto("Support Requests", "Receive support requests from users", "admin", true, + false, false, false, false, + true, false, false, false, false), + new UserEmailNotificationDto("Firmware Upgrade Failures", + "Receive automated firmware upgrade failure emails", + "operator", true, false, false, false, false, + true, false, false, false, false)); + + private static final List operatorOrgList = List.of("operator_org"); + private static final List operatorAdminList = List.of("operator_org, admin_org"); + + @BeforeEach + void setUp() { + authToken = Mockito.mock(CvManagerAuthToken.class); + } + + @Nested + @DisplayName("GET /users/subscriptions/email-subscriptions — list all subscriptions") + class GetAllSubscriptions { + + @Test + @DisplayName("returns 403 when no permissions are granted (unauthenticated)") + void noPermissions_returns403() throws Exception { + // Spring Security filter runs before argument binding; unauthenticated → 403 + mockMvc.perform(get("/users/subscriptions/email-subscriptions")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(get("/users/subscriptions/email-subscriptions")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with subscriptions list when has only user role") + void authenticated_returns200User() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn(email); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of()); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + when(emailService.getAllEmailSubscriptionOptionsForUser(email, false, false)) + .thenReturn(validSubscriptionsList); + + mockMvc.perform(get("/users/subscriptions/email-subscriptions")) + .andExpect(jsonPath("$.subscriptions").isArray()) + .andExpect(jsonPath("$.subscriptions[0].category").value("Support Requests")) + .andExpect(jsonPath("$.subscriptions[1].category").value("Firmware Upgrade Failures")) + .andExpect(jsonPath("$.email").value(email)); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with subscriptions list when has operator role") + void authenticated_returns200Operator() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn(email); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(operatorOrgList); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + when(emailService.getAllEmailSubscriptionOptionsForUser(email, true, false)) + .thenReturn(validSubscriptionsList); + + mockMvc.perform(get("/users/subscriptions/email-subscriptions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subscriptions").isArray()) + .andExpect(jsonPath("$.subscriptions[0].category").value("Support Requests")) + .andExpect(jsonPath("$.subscriptions[1].category").value("Firmware Upgrade Failures")) + .andExpect(jsonPath("$.email").value(email)); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with subscriptions list when has admin role") + void authenticated_returns200Admin() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn(email); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(operatorOrgList); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(operatorAdminList); + when(emailService.getAllEmailSubscriptionOptionsForUser(email, true, true)) + .thenReturn(validSubscriptionsList); + + mockMvc.perform(get("/users/subscriptions/email-subscriptions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subscriptions").isArray()) + .andExpect(jsonPath("$.subscriptions[0].category").value("Support Requests")) + .andExpect(jsonPath("$.subscriptions[1].category").value("Firmware Upgrade Failures")) + .andExpect(jsonPath("$.email").value(email)); + } + } + + @Nested + @DisplayName("POST /users/subscriptions/email-subscriptions — update all subscriptions") + class UpdateAllSubscriptions { + + @Test + @DisplayName("returns 403 when no permissions are granted (unauthenticated)") + void noPermissions_returns403() throws Exception { + // Spring Security filter runs before argument binding; unauthenticated → 403 + mockMvc.perform(post("/users/subscriptions/email-subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSubscriptionsList))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when authenticated but neither isSuperUser nor hasRole('USER')") + void authenticated_insufficientPermissions_returns403() throws Exception { + when(permissionService.isSuperUser()).thenReturn(false); + when(permissionService.hasRole(UserRole.USER)).thenReturn(false); + + mockMvc.perform(post("/users/subscriptions/email-subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSubscriptionsList))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with valid subscriptions list") + void superUser_returns200() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn(email); + doNothing().when(emailService).updateEmailSubscriptions(email, true, true, validSubscriptionsList); + + mockMvc.perform(post("/users/subscriptions/email-subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSubscriptionsList))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("returns 400 with invalid subscriptions list") + void superUser_returns400() throws Exception { + when(permissionService.isSuperUser()).thenReturn(true); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn(email); + doNothing().when(emailService).updateEmailSubscriptions(email, true, true, validSubscriptionsList); + + mockMvc.perform(post("/users/subscriptions/email-subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"invalid\": true}")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UnsubscribeControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UnsubscribeControllerTest.java new file mode 100644 index 000000000..78176acba --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UnsubscribeControllerTest.java @@ -0,0 +1,195 @@ +package us.dot.its.jpo.ode.api.controllers.users; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.UserEmailNotificationDto; +import us.dot.its.jpo.ode.api.models.postgres.tables.Role; +import us.dot.its.jpo.ode.api.models.postgres.tables.User; +import us.dot.its.jpo.ode.api.models.postgres.tables.UserOrganization; +import us.dot.its.jpo.ode.api.repositories.UserOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.UserRepository; +import us.dot.its.jpo.ode.api.services.EmailService; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("integration-test") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +public class UnsubscribeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private EmailService emailService; + + @MockitoBean + private UserRepository userRepository; + + @MockitoBean + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @MockitoBean + private UserOrganizationRepository userOrganizationRepository; + + private static final String validToken = "valid-token-123"; + private static final String invalidToken = "invalid-token-456"; + private static final String email = "test@example.com"; + + private static final List validSubscriptionList = Arrays.asList( + new UserEmailNotificationDto("Support Requests", "Receive support requests from users", "admin", true, + false, false, false, false, + true, false, false, false, false), + new UserEmailNotificationDto("Firmware Upgrade Failures", + "Receive automated firmware upgrade failure emails", + "operator", true, false, false, false, false, + true, false, false, false, false)); + + @Nested + @DisplayName("GET /users/unsubscribe/email-subscriptions — list all subscriptions") + class GetAllSubscriptions { + + @Test + @DisplayName("returns 400 when no token is provided") + void noToken_returns400() throws Exception { + mockMvc.perform(get("/users/unsubscribe/email-subscriptions")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when token is invalid") + void authenticated_invalidToken_returns403() throws Exception { + when(unsubscribeTokenGenerator.parseAndValidateToken(invalidToken)).thenReturn(null); + + mockMvc.perform(get("/users/unsubscribe/email-subscriptions") + .header("Authorization", invalidToken)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with valid token and permissions") + void authenticated_validToken_200() throws Exception { + User mockUser = mock(User.class); + when(mockUser.getSuperUser()).thenReturn(true); + when(userRepository.findByEmail(email)).thenReturn(mockUser); + + Role roleOperator = mock(Role.class); + when(roleOperator.getName()).thenReturn("operator"); + UserOrganization orgOperator = mock(UserOrganization.class); + when(orgOperator.getRole()).thenReturn(roleOperator); + + Role roleAdmin = mock(Role.class); + when(roleAdmin.getName()).thenReturn("admin"); + UserOrganization orgAdmin = mock(UserOrganization.class); + when(orgAdmin.getRole()).thenReturn(roleAdmin); + + List authToken = Arrays.asList(orgAdmin, orgOperator); + when(userOrganizationRepository.findAllByEmail(email)).thenReturn(authToken); + + when(unsubscribeTokenGenerator.parseAndValidateToken(validToken)).thenReturn(email); + when(emailService.getAllEmailSubscriptionOptionsForUser(email, true, true)) + .thenReturn(validSubscriptionList); + + mockMvc.perform(get("/users/unsubscribe/email-subscriptions") + .header("Authorization", validToken)) + .andExpect(status().isOk()); + + verify(emailService).getAllEmailSubscriptionOptionsForUser(email, true, true); + } + } + + @Nested + @DisplayName("POST /users/unsubscribe/email-subscriptions — update all subscriptions") + class UpdateAllSubscriptions { + + @Test + @DisplayName("returns 400 when no token is provided") + void noToken_returns400() throws Exception { + mockMvc.perform(post("/users/unsubscribe/email-subscriptions")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + @DisplayName("returns 403 when token is invalid") + void authenticated_invalidToken_returns403() throws Exception { + when(unsubscribeTokenGenerator.parseAndValidateToken(validToken)).thenReturn(null); + + mockMvc.perform(post("/users/unsubscribe/email-subscriptions") + .header("Authorization", invalidToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSubscriptionList))) + .andExpect(status().isForbidden()); + + verify(emailService, never()).updateEmailSubscriptions(email, true, true, validSubscriptionList); + } + + @Test + @WithMockUser + @DisplayName("returns 200 with valid subscriptions list") + void superUser_returns200() throws Exception { + User mockUser = mock(User.class); + when(mockUser.getSuperUser()).thenReturn(true); + when(userRepository.findByEmail(email)).thenReturn(mockUser); + + when(unsubscribeTokenGenerator.parseAndValidateToken(validToken)).thenReturn(email); + doNothing().when(emailService).updateEmailSubscriptions(email, true, true, validSubscriptionList); + + mockMvc.perform(post("/users/unsubscribe/email-subscriptions") + .header("Authorization", validToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validSubscriptionList))) + .andExpect(status().isOk()); + + verify(emailService).updateEmailSubscriptions(email, true, true, validSubscriptionList); + } + + @Test + @WithMockUser + @DisplayName("returns 400 with invalid subscriptions list") + void superUser_returns400() throws Exception { + when(unsubscribeTokenGenerator.parseAndValidateToken(validToken)).thenReturn(email); + doNothing().when(emailService).updateEmailSubscriptions(email, true, true, validSubscriptionList); + + mockMvc.perform(post("/users/unsubscribe/email-subscriptions") + .header("Authorization", validToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"invalid\": true}")) + .andExpect(status().isBadRequest()); + + verify(emailService, never()).updateEmailSubscriptions(email, true, true, validSubscriptionList); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UserControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UserControllerTest.java new file mode 100644 index 000000000..5cbb749c6 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/users/UserControllerTest.java @@ -0,0 +1,682 @@ +package us.dot.its.jpo.ode.api.controllers.users; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.server.ResponseStatusException; + +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.models.postgres.tables.User; +import us.dot.its.jpo.ode.api.models.users.ModifyUserAllowedSelections; +import us.dot.its.jpo.ode.api.models.users.UserDto; +import us.dot.its.jpo.ode.api.models.users.UserOrganizationDto; +import us.dot.its.jpo.ode.api.models.users.UserPatch; +import us.dot.its.jpo.ode.api.services.PermissionService; +import us.dot.its.jpo.ode.api.services.UserManagementService; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserControllerTest { + + @Mock + private UserManagementService userManagementService; + + @Mock + private PermissionService permissionService; + + @Mock + private CvManagerAuthToken authToken; + + @InjectMocks + private UserController userController; + + private UserDto testUserDto; + private String testToken = "Bearer mock-jwt-token"; + + @BeforeEach + void setUp() { + // Set up test user DTO + testUserDto = new UserDto("test@example.com", "Test", "User", false, List.of()); + + // Set up mock request context + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", testToken); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + // ==================== getUsers Tests ==================== + + @Test + void testGetUsers_Success() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals("test@example.com", result.getContent().get(0).getEmail()); + verify(userManagementService).getUsers(eq(organization), eq(search), any(Pageable.class)); + } + + @Test + void testGetUsers_WithSearch() { + String organization = "TestOrg"; + String search = "test"; + Pageable pageable = PageRequest.of(0, 100); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + verify(userManagementService).getUsers(eq(organization), eq(search), any(Pageable.class)); + } + + @Test + void testGetUsers_WithSorting_FirstName() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by("first_name").ascending()); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + verify(userManagementService).getUsers(eq(organization), eq(search), argThat(p -> { + Sort.Order order = p.getSort().iterator().next(); + return "firstName".equals(order.getProperty()) && order.isAscending(); + })); + } + + @Test + void testGetUsers_WithSorting_LastName() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by("last_name").descending()); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + verify(userManagementService).getUsers(eq(organization), eq(search), argThat(p -> { + Sort.Order order = p.getSort().iterator().next(); + return "lastName".equals(order.getProperty()) && order.isDescending(); + })); + } + + @Test + void testGetUsers_WithSorting_SuperUser() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by("super_user").ascending()); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + verify(userManagementService).getUsers(eq(organization), eq(search), argThat(p -> { + Sort.Order order = p.getSort().iterator().next(); + return "superUser".equals(order.getProperty()); + })); + } + + @Test + void testGetUsers_WithSorting_UnmappedField() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100, Sort.by("email").ascending()); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + // Should keep original field name if not in mapping + verify(userManagementService).getUsers(eq(organization), eq(search), argThat(p -> { + Sort.Order order = p.getSort().iterator().next(); + return "email".equals(order.getProperty()); + })); + } + + @Test + void testGetUsers_NoSorting() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(0, 100); + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + verify(userManagementService).getUsers(eq(organization), eq(search), argThat(p -> !p.getSort().isSorted())); + } + + @Test + void testGetUsers_EmptyResults() { + String organization = "TestOrg"; + String search = "nonexistent"; + Pageable pageable = PageRequest.of(0, 100); + Page userPage = new PageImpl<>(new ArrayList<>(), pageable, 0); + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + @Test + void testGetUsers_Pagination() { + String organization = "TestOrg"; + String search = ""; + Pageable pageable = PageRequest.of(1, 25); // Page 2, size 25 + List users = List.of(testUserDto); + Page userPage = new PageImpl<>(users, pageable, 100); // 100 total + + when(userManagementService.getUsers(eq(organization), eq(search), any(Pageable.class))) + .thenReturn(userPage); + + Page result = userController.getUsers(organization, search, pageable); + + assertNotNull(result); + assertEquals(1, result.getNumber()); // Page number + assertEquals(25, result.getSize()); // Page size + assertEquals(100, result.getTotalElements()); // Total elements + assertEquals(4, result.getTotalPages()); // Total pages (100/25) + } + + // ==================== getSingleUser Tests ==================== + + @Test + void testGetSingleUser_Success() { + String email = "test@example.com"; + when(userManagementService.getUser(email)).thenReturn(testUserDto); + + UserDto result = userController.getSingleUser(email); + + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + verify(userManagementService).getUser(email); + } + + @Test + void testGetSingleUser_DifferentEmail() { + String email = "another@example.com"; + UserDto anotherUser = new UserDto(email, "Another", "User", false, List.of()); + when(userManagementService.getUser(email)).thenReturn(anotherUser); + + UserDto result = userController.getSingleUser(email); + + assertNotNull(result); + assertEquals(email, result.getEmail()); + verify(userManagementService).getUser(email); + } + + // ==================== getAllowedSelections Tests ==================== + + @Test + void testGetAllowedSelections_Success() { + ModifyUserAllowedSelections allowedSelections = new ModifyUserAllowedSelections(); + allowedSelections.setRoles(List.of("admin", "operator", "user")); + allowedSelections.setOrganizations(List.of("TestOrg", "AnotherOrg")); + + when(userManagementService.getAllowedSelections(any(CvManagerAuthToken.class))) + .thenReturn(allowedSelections); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + + ModifyUserAllowedSelections result = userController.getAllowedSelections(); + + assertNotNull(result); + assertEquals(3, result.getRoles().size()); + assertEquals(2, result.getOrganizations().size()); + assertTrue(result.getRoles().contains("admin")); + assertTrue(result.getOrganizations().contains("TestOrg")); + verify(userManagementService).getAllowedSelections(authToken); + } + + @Test + void testGetAllowedSelections_EmptySelections() { + ModifyUserAllowedSelections allowedSelections = new ModifyUserAllowedSelections(); + allowedSelections.setRoles(new ArrayList<>()); + allowedSelections.setOrganizations(new ArrayList<>()); + + when(userManagementService.getAllowedSelections(any(CvManagerAuthToken.class))) + .thenReturn(allowedSelections); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + + ModifyUserAllowedSelections result = userController.getAllowedSelections(); + + assertNotNull(result); + assertTrue(result.getRoles().isEmpty()); + assertTrue(result.getOrganizations().isEmpty()); + } + + // ==================== createUser Tests ==================== + + @Test + void testCreateUser_Success() { + UserOrganizationDto org1 = new UserOrganizationDto(); + org1.setOrganization("TestOrg"); + org1.setRole("USER"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org1)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg"))) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg")); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_MultipleOrganizations() { + UserOrganizationDto org1 = new UserOrganizationDto(); + org1.setOrganization("TestOrg"); + org1.setRole("USER"); + + UserOrganizationDto org2 = new UserOrganizationDto(); + org2.setOrganization("AnotherOrg"); + org2.setRole("OPERATOR"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org1, org2)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg", "AnotherOrg"))) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg", "AnotherOrg")); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_WithSuperUserFlag() { + UserOrganizationDto org = new UserOrganizationDto(); + org.setOrganization("TestOrg"); + org.setRole("ADMIN"); + + UserDto newUser = new UserDto( + "admin@example.com", + "Admin", + "User", + true, // super_user = true + List.of(org)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg"))) + .thenReturn(true); + when(permissionService.isSuperUser()) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + assertTrue(newUser.getSuperUser()); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_Forbidden_NoAdminRoleInOrganization() { + UserOrganizationDto org = new UserOrganizationDto(); + org.setOrganization("TestOrg"); + org.setRole("USER"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg"))) + .thenReturn(false); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> userController.createUser(newUser)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("User not qualified to modify all specified organizations", exception.getReason()); + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg")); + verify(userManagementService, never()).createUser(any()); + } + + @Test + void testCreateUser_Forbidden_NoAdminRoleInOneOfMultipleOrganizations() { + UserOrganizationDto org1 = new UserOrganizationDto(); + org1.setOrganization("TestOrg"); + org1.setRole("USER"); + + UserOrganizationDto org2 = new UserOrganizationDto(); + org2.setOrganization("UnauthorizedOrg"); + org2.setRole("USER"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org1, org2)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg", "UnauthorizedOrg"))) + .thenReturn(false); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> userController.createUser(newUser)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("User not qualified to modify all specified organizations", exception.getReason()); + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg", "UnauthorizedOrg")); + verify(userManagementService, never()).createUser(any()); + } + + @Test + void testCreateUser_NonexistentOrganization() { + UserOrganizationDto org = new UserOrganizationDto(); + org.setOrganization("NonexistentOrg"); + org.setRole("USER"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org)); + + // hasRoleInOrgs returns false for nonexistent organizations + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("NonexistentOrg"))) + .thenReturn(false); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> userController.createUser(newUser)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("User not qualified to modify all specified organizations", exception.getReason()); + verify(userManagementService, never()).createUser(any()); + } + + @Test + void testCreateUser_EmptyOrganizationsList() { + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of() // Empty organizations list + ); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of())) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of()); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_WithDifferentRoles() { + UserOrganizationDto orgAdmin = new UserOrganizationDto(); + orgAdmin.setOrganization("TestOrg1"); + orgAdmin.setRole("ADMIN"); + + UserOrganizationDto orgOperator = new UserOrganizationDto(); + orgOperator.setOrganization("TestOrg2"); + orgOperator.setRole("OPERATOR"); + + UserOrganizationDto orgUser = new UserOrganizationDto(); + orgUser.setOrganization("TestOrg3"); + orgUser.setRole("USER"); + + UserDto newUser = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgAdmin, orgOperator, orgUser)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg1", "TestOrg2", "TestOrg3"))) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg1", "TestOrg2", "TestOrg3")); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_WithSpecialCharactersInEmail() { + UserOrganizationDto org = new UserOrganizationDto(); + org.setOrganization("TestOrg"); + org.setRole("USER"); + + UserDto newUser = new UserDto( + "new.user+tag@example.co.uk", + "New", + "User", + false, + List.of(org)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg"))) + .thenReturn(true); + when(userManagementService.createUser(newUser)).thenReturn(new User()); + + userController.createUser(newUser); + + assertEquals("new.user+tag@example.co.uk", newUser.getEmail()); + verify(userManagementService).createUser(newUser); + } + + @Test + void testCreateUser_ServiceLayerValidationHandled() { + UserOrganizationDto org = new UserOrganizationDto(); + org.setOrganization("TestOrg"); + org.setRole("USER"); + + UserDto newUser = new UserDto( + "duplicate@example.com", + "New", + "User", + false, + List.of(org)); + + when(permissionService.hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg"))) + .thenReturn(true); + doThrow(new IllegalArgumentException("User with email already exists")) + .when(userManagementService).createUser(newUser); + + assertThrows(IllegalArgumentException.class, () -> userController.createUser(newUser)); + verify(permissionService).hasRoleInOrgs(UserRole.ADMIN, List.of("TestOrg")); + verify(userManagementService).createUser(newUser); + } + + // ==================== modifyUser Tests ==================== + + @Test + void testModifyUser_Success() { + String email = "test@example.com"; + UserPatch userPatch = new UserPatch(); + userPatch.setFirstName("Updated"); + userPatch.setLastName("Name"); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(userManagementService.modifyUser(email, userPatch, authToken)) + .thenReturn(testUserDto); + + ResponseEntity result = userController.modifyUser(email, userPatch); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).modifyUser(email, userPatch, authToken); + } + + @Test + void testModifyUser_WithOrganizationChanges() { + String email = "test@example.com"; + UserPatch userPatch = new UserPatch(); + userPatch.setOrganizationsToAdd(List.of()); + userPatch.setOrganizationsToRemove(List.of()); + + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(userManagementService.modifyUser(email, userPatch, authToken)) + .thenReturn(testUserDto); + + ResponseEntity result = userController.modifyUser(email, userPatch); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).modifyUser(email, userPatch, authToken); + } + + // ==================== deleteUser Tests ==================== + + @Test + void testDeleteUser_Success() { + String email = "test@example.com"; + doNothing().when(userManagementService).deleteUserByEmail(email); + + ResponseEntity result = userController.deleteUser(email); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteUserByEmail(email); + } + + @Test + void testDeleteUser_DifferentEmail() { + String email = "another@example.com"; + doNothing().when(userManagementService).deleteUserByEmail(email); + + ResponseEntity result = userController.deleteUser(email); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteUserByEmail(email); + } + + // ==================== deleteUsers Tests ==================== + + @Test + void testDeleteUsers_Success() { + List emails = List.of("test1@example.com", "test2@example.com"); + doNothing().when(userManagementService).deleteMultipleUsersByEmail(emails); + + ResponseEntity result = userController.deleteUsers(emails); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteMultipleUsersByEmail(emails); + } + + @Test + void testDeleteUsers_SingleUser() { + List emails = List.of("test@example.com"); + doNothing().when(userManagementService).deleteMultipleUsersByEmail(emails); + + ResponseEntity result = userController.deleteUsers(emails); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteMultipleUsersByEmail(emails); + } + + @Test + void testDeleteUsers_MultipleUsers() { + List emails = List.of( + "test1@example.com", + "test2@example.com", + "test3@example.com", + "test4@example.com"); + doNothing().when(userManagementService).deleteMultipleUsersByEmail(emails); + + ResponseEntity result = userController.deleteUsers(emails); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteMultipleUsersByEmail(emails); + } + + @Test + void testDeleteUsers_EmptyList() { + List emails = new ArrayList<>(); + doNothing().when(userManagementService).deleteMultipleUsersByEmail(emails); + + ResponseEntity result = userController.deleteUsers(emails); + + assertNotNull(result); + assertEquals(HttpStatus.NO_CONTENT, result.getStatusCode()); + verify(userManagementService).deleteMultipleUsersByEmail(emails); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/BsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/BsmDecoderTests.java deleted file mode 100644 index d945e8d09..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/BsmDecoderTests.java +++ /dev/null @@ -1,106 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.geojsonconverter.pojos.geojson.Point; -import us.dot.its.jpo.geojsonconverter.pojos.geojson.bsm.ProcessedBsm; -import us.dot.its.jpo.ode.api.asn1.BsmDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class BsmDecoderTests { - - private final BsmDecoder bsmDecoder; - - private String odeBsmDecodedXmlReference = ""; - private String odeBsmDecodedJsonReference = ""; - private String processedBsmReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public BsmDecoderTests(BsmDecoder bsmDecoder) { - this.bsmDecoder = bsmDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - odeBsmDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceBsmXER.xml"))); - - odeBsmDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/bsm/Ode.ReferenceBsmJson.json"))); - - processedBsmReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/bsm/GJC.ReferenceProcessedBsmJson.json"))); - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData bsm = bsmDecoder.convertXERToMessageFrame(odeBsmDecodedXmlReference); - - bsm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - bsm.getMetadata() - .setSerialId(bsm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeBsmDecodedJsonReference).isEqualTo(bsm.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } - - /** - * Test to verify Conversion from a OdeMessageFrame object to a ProcessedBSM - * Object - */ - @Test - public void testConvertMessageFrameToProcessedBsm() { - ObjectMapper objectMapper = DateJsonMapper.getInstance(); - - try { - OdeMessageFrameData bsmMessageFrame = objectMapper.readValue(odeBsmDecodedJsonReference, - OdeMessageFrameData.class); - - ProcessedBsm bsm = bsmDecoder.convertMessageFrameToProcessedBsm(bsmMessageFrame); - - bsm.getProperties().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - - assertThatJson(processedBsmReference).isEqualTo(bsm.toString()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/MapDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/MapDecoderTests.java deleted file mode 100644 index b81bf7706..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/MapDecoderTests.java +++ /dev/null @@ -1,108 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.geojsonconverter.pojos.geojson.LineString; -import us.dot.its.jpo.geojsonconverter.pojos.geojson.map.ProcessedMap; -import us.dot.its.jpo.ode.api.asn1.MapDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.ZonedDateTime; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class MapDecoderTests { - - private final MapDecoder mapDecoder; - - private String odeMapDecodedXmlReference = ""; - private String odeMapDecodedJsonReference = ""; - private String processedMapReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public MapDecoderTests(MapDecoder mapDecoder) { - this.mapDecoder = mapDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - - odeMapDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceMapXER.xml"))); - - odeMapDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/map/Ode.ReferenceMapJson.json"))); - - processedMapReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/map/GJC.ReferenceProcessedMapJson.json"))); - } catch (IOException e) { - e.printStackTrace(); - } - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData spat = mapDecoder.convertXERToMessageFrame(odeMapDecodedXmlReference); - - spat.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - spat.getMetadata() - .setSerialId(spat.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeMapDecodedJsonReference).isEqualTo(spat.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } - - /** - * Test to verify Conversion from a OdeMessageFrame object to a ProcessedMAP - * Object - */ - @Test - public void testConvertMessageFrameToProcessedMap() { - ObjectMapper objectMapper = DateJsonMapper.getInstance(); - - try { - OdeMessageFrameData mapMessageFrame = objectMapper.readValue(odeMapDecodedJsonReference, - OdeMessageFrameData.class); - - ProcessedMap map = mapDecoder.convertMessageFrameToProcessedMap(mapMessageFrame); - - map.getProperties().setOdeReceivedAt(ZonedDateTime.parse("2025-08-29T16:09:34.416Z")); - - assertThatJson(processedMapReference).isEqualTo(map.toString()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/PsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/PsmDecoderTests.java deleted file mode 100644 index ca58edcf7..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/PsmDecoderTests.java +++ /dev/null @@ -1,78 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.ode.api.asn1.PsmDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class PsmDecoderTests { - - private final PsmDecoder psmDecoder; - - private String odePsmDecodedXmlReference = ""; - private String odePsmDecodedJsonReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public PsmDecoderTests(PsmDecoder psmDecoder) { - this.psmDecoder = psmDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - odePsmDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferencePsmXER.xml"))); - - odePsmDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/psm/Ode.ReferencePsmJson.json"))); - - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData psm = psmDecoder.convertXERToMessageFrame(odePsmDecodedXmlReference); - - psm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - psm.getMetadata() - .setSerialId(psm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odePsmDecodedJsonReference).isEqualTo(psm.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SpatDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SpatDecoderTests.java deleted file mode 100644 index 1faefebcd..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SpatDecoderTests.java +++ /dev/null @@ -1,108 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.geojsonconverter.pojos.spat.ProcessedSpat; -import us.dot.its.jpo.ode.api.asn1.SpatDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import java.nio.file.Files; -import java.nio.file.Paths; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class SpatDecoderTests { - - private final SpatDecoder spatDecoder; - - private String odeSpatDecodedXmlReference = ""; - private String odeSpatDecodedJsonReference = ""; - private String processedSpatReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public SpatDecoderTests(SpatDecoder spatDecoder) { - this.spatDecoder = spatDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - - odeSpatDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSpatXER.xml"))); - - odeSpatDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/spat/Ode.ReferenceSpatJson.json"))); - - processedSpatReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/spat/GJC.ReferenceProcessedSpatJson.json"))); - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData spat = spatDecoder.convertXERToMessageFrame(odeSpatDecodedXmlReference); - - spat.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - spat.getMetadata() - .setSerialId(spat.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeSpatDecodedJsonReference).isEqualTo(spat.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } - - /** - * Test to verify Conversion from a OdeMessageFrame object to a ProcessedSPAT - * Object - */ - @Test - public void testConvertMessageFrameToProcessedSpat() { - - try { - OdeMessageFrameData spatMessageFrame = objectMapper.readValue(odeSpatDecodedJsonReference, - OdeMessageFrameData.class); - - spatMessageFrame.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - - ProcessedSpat spat = spatDecoder.convertMessageFrameToProcessedSpat(spatMessageFrame); - - spat.setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - - assertThatJson(processedSpatReference).isEqualTo(spat.toString()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SrmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SrmDecoderTests.java deleted file mode 100644 index 21bcf820a..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SrmDecoderTests.java +++ /dev/null @@ -1,79 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.ode.api.asn1.SrmDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class SrmDecoderTests { - - private final SrmDecoder srmDecoder; - - private String odeSrmDecodedXmlReference = ""; - private String odeSrmDecodedJsonReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public SrmDecoderTests(SrmDecoder srmDecoder) { - this.srmDecoder = srmDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - odeSrmDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSrmXER.xml"))); - - odeSrmDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/srm/Ode.ReferenceSrmJson.json"))) - .replaceAll("\n", "").replaceAll(" ", ""); - - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData srm = srmDecoder.convertXERToMessageFrame(odeSrmDecodedXmlReference); - - srm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - srm.getMetadata() - .setSerialId(srm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeSrmDecodedJsonReference).isEqualTo(srm.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SsmDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SsmDecoderTests.java deleted file mode 100644 index b8c375833..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/SsmDecoderTests.java +++ /dev/null @@ -1,77 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.ode.api.asn1.SsmDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class SsmDecoderTests { - - private final SsmDecoder ssmDecoder; - - private String odeSsmDecodedXmlReference = ""; - private String odeSsmDecodedJsonReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public SsmDecoderTests(SsmDecoder ssmDecoder) { - this.ssmDecoder = ssmDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - odeSsmDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceSsmXER.xml"))); - - odeSsmDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/ssm/Ode.ReferenceSsmJson.json"))); - - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData ssm = ssmDecoder.convertXERToMessageFrame(odeSsmDecodedXmlReference); - - ssm.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - ssm.getMetadata() - .setSerialId(ssm.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeSsmDecodedJsonReference).isEqualTo(ssm.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/TimDecoderTests.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/TimDecoderTests.java deleted file mode 100644 index 3effcbfb7..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/decoderTests/TimDecoderTests.java +++ /dev/null @@ -1,78 +0,0 @@ -package us.dot.its.jpo.ode.api.decoderTests; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.zonky.test.db.AutoConfigureEmbeddedDatabase; -import us.dot.its.jpo.geojsonconverter.DateJsonMapper; -import us.dot.its.jpo.ode.api.asn1.TimDecoder; -import us.dot.its.jpo.ode.model.OdeMessageFrameData; - -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; - -@SpringBootTest -@RunWith(SpringRunner.class) -@ActiveProfiles("test") -@AutoConfigureEmbeddedDatabase -public class TimDecoderTests { - - private final TimDecoder timDecoder; - - private String odeTimDecodedXmlReference = ""; - private String odeTimDecodedJsonReference = ""; - - ObjectMapper objectMapper; - - @Autowired - public TimDecoderTests(TimDecoder timDecoder) { - this.timDecoder = timDecoder; - - objectMapper = DateJsonMapper.getInstance(); - - try { - odeTimDecodedXmlReference = new String( - Files.readAllBytes(Paths.get("src/test/resources/xml/Ode.ReferenceTimXER.xml"))); - - odeTimDecodedJsonReference = new String( - Files.readAllBytes(Paths - .get("src/test/resources/json/tim/Ode.ReferenceTimJson.json"))); - - } catch (IOException e) { - e.printStackTrace(); - } - - } - - /** - * Test verifying the conversion from String XML data to OdeMessageFrame - * Object - */ - @Test - public void testGetAsMessageFrame() { - try { - OdeMessageFrameData tim = timDecoder.convertXERToMessageFrame(odeTimDecodedXmlReference); - - tim.getMetadata().setOdeReceivedAt("2025-08-29T16:09:34.416Z"); - tim.getMetadata() - .setSerialId(tim.getMetadata().getSerialId().setStreamId("44a6d71c-8af1-4f45-848c-10bd7f919be8")); - - assertThatJson(odeTimDecodedJsonReference).isEqualTo(tim.toJson()); - } catch (JsonProcessingException e) { - assertEquals(true, false); - } - } -} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/UnsubscribeTokenGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/UnsubscribeTokenGeneratorTest.java new file mode 100644 index 000000000..21e8817f8 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/UnsubscribeTokenGeneratorTest.java @@ -0,0 +1,362 @@ +package us.dot.its.jpo.ode.api.emails; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +@ExtendWith(MockitoExtension.class) +public class UnsubscribeTokenGeneratorTest { + + @Mock + private EmailProperties emailProperties; + + @InjectMocks + private UnsubscribeTokenGenerator tokenGenerator; + + private static final String TEST_EMAIL = "test@example.com"; + private static final String SECRET_KEY = "this-is-a-very-secure-secret-key-for-testing-purposes-123456"; + private static final String ISSUER_URI = "http://localhost:8080/realms/test"; + private static final String FRONTEND_URI = "http://localhost:3000"; + + @BeforeEach + void setUp() throws Exception { + lenient().when(emailProperties.getUnsubscribeSecretKey()).thenReturn(SECRET_KEY); + lenient().when(emailProperties.getCvmgrFrontEndUri()).thenReturn(FRONTEND_URI); + + // Use reflection to set the kcIssuerUri field + Field issuerField = UnsubscribeTokenGenerator.class.getDeclaredField("kcIssuerUri"); + issuerField.setAccessible(true); + issuerField.set(tokenGenerator, ISSUER_URI); + } + + @Test + void testGenerateUnsubscribeToken_ValidEmail() { + // Act + String token = tokenGenerator.generateUnsubscribeToken(TEST_EMAIL); + + // Assert + assertNotNull(token); + assertTrue(token.length() > 0); + + // Verify it's a valid JWT format (three parts separated by dots) + String[] parts = token.split("\\."); + assertEquals(3, parts.length, "JWT should have three parts"); + } + + @Test + void testGenerateUnsubscribeToken_DifferentEmails_ProduceDifferentTokens() { + // Act + String token1 = tokenGenerator.generateUnsubscribeToken("user1@example.com"); + String token2 = tokenGenerator.generateUnsubscribeToken("user2@example.com"); + + // Assert + assertNotNull(token1); + assertNotNull(token2); + assertTrue(!token1.equals(token2), "Different emails should produce different tokens"); + } + + @Test + void testGenerateUnsubscribeUrl_ValidEmail() { + // Act + String url = tokenGenerator.generateUnsubscribeUrl(TEST_EMAIL); + + // Assert + assertNotNull(url); + assertTrue(url.startsWith(FRONTEND_URI + "/unsubscribe?token=")); + + // Extract and verify the token parameter is URL-encoded + String tokenPart = url.substring((FRONTEND_URI + "/unsubscribe?token=").length()); + assertNotNull(tokenPart); + assertTrue(tokenPart.length() > 0); + } + + @Test + void testGenerateUnsubscribeUrl_ContainsValidToken() throws Exception { + // Act + String url = tokenGenerator.generateUnsubscribeUrl(TEST_EMAIL); + + // Assert + String tokenPart = url.substring((FRONTEND_URI + "/unsubscribe?token=").length()); + String decodedToken = URLDecoder.decode(tokenPart, StandardCharsets.UTF_8); + + // Parse the JWT to verify it's valid + SignedJWT signedJWT = SignedJWT.parse(decodedToken); + assertNotNull(signedJWT); + assertEquals(TEST_EMAIL, signedJWT.getJWTClaimsSet().getSubject()); + } + + @Test + void testParseAndValidateToken_ValidToken() { + // Arrange + String token = tokenGenerator.generateUnsubscribeToken(TEST_EMAIL); + + // Act + String email = tokenGenerator.parseAndValidateToken(token); + + // Assert + assertNotNull(email); + assertEquals(TEST_EMAIL, email); + } + + @Test + void testParseAndValidateToken_InvalidSignature() throws Exception { + // Arrange - Create a token with a different secret key + String differentSecretKey = "a-completely-different-secret-key-that-wont-match-the-original"; + JWSSigner signer = new MACSigner(differentSecretKey); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + .claim("purpose", "unsubscribe") + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String invalidToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(invalidToken); + + // Assert + assertNull(email, "Token with invalid signature should return null"); + } + + @Test + void testParseAndValidateToken_ExpiredToken() throws Exception { + // Arrange - Create a token that expired 1 hour ago + JWSSigner signer = new MACSigner(SECRET_KEY); + + Date oneHourAgo = new Date(System.currentTimeMillis() - 3600000); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + .claim("purpose", "unsubscribe") + .issueTime(new Date(System.currentTimeMillis() - 7200000)) // 2 hours ago + .expirationTime(oneHourAgo) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String expiredToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(expiredToken); + + // Assert + assertNull(email, "Expired token should return null"); + } + + @Test + void testParseAndValidateToken_WrongIssuer() throws Exception { + // Arrange - Create a token with wrong issuer + JWSSigner signer = new MACSigner(SECRET_KEY); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer("http://wrong-issuer.com") + .subject(TEST_EMAIL) + .claim("purpose", "unsubscribe") + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String invalidToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(invalidToken); + + // Assert + assertNull(email, "Token with wrong issuer should return null"); + } + + @Test + void testParseAndValidateToken_WrongPurpose() throws Exception { + // Arrange - Create a token with wrong purpose + JWSSigner signer = new MACSigner(SECRET_KEY); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + .claim("purpose", "password-reset") // Wrong purpose + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String invalidToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(invalidToken); + + // Assert + assertNull(email, "Token with wrong purpose should return null"); + } + + @Test + void testParseAndValidateToken_MissingPurpose() throws Exception { + // Arrange - Create a token without purpose claim + JWSSigner signer = new MACSigner(SECRET_KEY); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + // No purpose claim + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String invalidToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(invalidToken); + + // Assert + assertNull(email, "Token without purpose claim should return null"); + } + + @Test + void testParseAndValidateToken_MalformedToken() { + // Arrange + String malformedToken = "this.is.not.a.valid.jwt"; + + // Act + String email = tokenGenerator.parseAndValidateToken(malformedToken); + + // Assert + assertNull(email, "Malformed token should return null"); + } + + @Test + void testParseAndValidateToken_EmptyToken() { + // Act + String email = tokenGenerator.parseAndValidateToken(""); + + // Assert + assertNull(email, "Empty token should return null"); + } + + @Test + void testParseAndValidateToken_NullToken() { + // Act + String email = tokenGenerator.parseAndValidateToken(null); + + // Assert + assertNull(email, "Null token should return null"); + } + + @Test + void testParseAndValidateToken_ValidTokenWithoutExpiration() throws Exception { + // Arrange - Create a valid token without expiration time (should be valid) + JWSSigner signer = new MACSigner(SECRET_KEY); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + .claim("purpose", "unsubscribe") + .issueTime(new Date()) + // No expiration time + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String validToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(validToken); + + // Assert + assertNotNull(email, "Token without expiration should be valid"); + assertEquals(TEST_EMAIL, email); + } + + @Test + void testParseAndValidateToken_ValidTokenWithFutureExpiration() throws Exception { + // Arrange - Create a token that expires in 1 hour + JWSSigner signer = new MACSigner(SECRET_KEY); + + Date oneHourFromNow = new Date(System.currentTimeMillis() + 3600000); + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER_URI) + .subject(TEST_EMAIL) + .claim("purpose", "unsubscribe") + .issueTime(new Date()) + .expirationTime(oneHourFromNow) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + String validToken = signedJWT.serialize(); + + // Act + String email = tokenGenerator.parseAndValidateToken(validToken); + + // Assert + assertNotNull(email, "Token with future expiration should be valid"); + assertEquals(TEST_EMAIL, email); + } + + @Test + void testGenerateUnsubscribeToken_TooShortSecretKey() throws Exception { + // Arrange - Mock a secret key that's too short (less than 32 bytes for HS256) + when(emailProperties.getUnsubscribeSecretKey()).thenReturn("short"); + + // Act + String token = tokenGenerator.generateUnsubscribeToken(TEST_EMAIL); + + // Assert + assertNull(token, "Should return null when secret key is too short"); + } + + @Test + void testRoundTrip_GenerateAndValidate() { + // Act - Generate a token and then validate it + String generatedToken = tokenGenerator.generateUnsubscribeToken(TEST_EMAIL); + String extractedEmail = tokenGenerator.parseAndValidateToken(generatedToken); + + // Assert + assertNotNull(generatedToken); + assertNotNull(extractedEmail); + assertEquals(TEST_EMAIL, extractedEmail); + } + + @Test + void testGenerateUnsubscribeUrl_SpecialCharactersInEmail() { + // Arrange + String emailWithSpecialChars = "test+tag@example.com"; + + // Act + String url = tokenGenerator.generateUnsubscribeUrl(emailWithSpecialChars); + String tokenPart = url.substring((FRONTEND_URI + "/unsubscribe?token=").length()); + String decodedToken = URLDecoder.decode(tokenPart, StandardCharsets.UTF_8); + String extractedEmail = tokenGenerator.parseAndValidateToken(decodedToken); + + // Assert + assertNotNull(url); + assertNotNull(extractedEmail); + assertEquals(emailWithSpecialChars, extractedEmail); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/ApiErrorEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/ApiErrorEmailGeneratorTest.java new file mode 100644 index 000000000..f96c11cd9 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/ApiErrorEmailGeneratorTest.java @@ -0,0 +1,110 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.ApiErrorEmailContents; + +@ExtendWith(MockitoExtension.class) +class ApiErrorEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private ApiErrorEmailGenerator apiErrorEmailGenerator; + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + ApiErrorEmailGenerator snapshotGenerator = new ApiErrorEmailGenerator(springTemplateEngine, + unsubscribeTokenGenerator, + emailProperties); + + Instant timestamp = Instant.parse("2024-02-11T10:30:00Z"); + String logsLink = "https://cvmanager.com/logs"; + String errorMessage = """ + NullPointerException occurred + + And a message with 'quotes', \"double quotes\", and tags & ampersands + """; + String stackTrace = """ + at com.example.Service.method(Service.java:42) + at com.example.Controller.handle(Controller.java:27)"""; + + ApiErrorEmailContents contents = new ApiErrorEmailContents(errorMessage, stackTrace, timestamp, logsLink); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/api_error_email_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + apiErrorEmailGenerator = new ApiErrorEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + apiErrorEmailGenerator = spy(apiErrorEmailGenerator); + + Context thymeLeafContext = mock(Context.class); + Instant ts = Instant.parse("2024-02-11T10:30:00Z"); + ApiErrorEmailContents data = new ApiErrorEmailContents("Error message", "stack trace", ts, + "https://cvmanager.com/logs"); + + doCallRealMethod().when(apiErrorEmailGenerator).generateEmailBody(any()); + + when(apiErrorEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template", thymeLeafContext)).thenReturn("HTML CONTENT"); + + EmailContent result = apiErrorEmailGenerator.generateEmailBody(data); + + EmailContent expectedResult = new EmailContent("CV-Manager API Error", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(3)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "CV-Manager API Error"); + verify(thymeLeafContext).setVariable("content_1", + "

A critical API Error has occurred at 2024-02-11T10:30:00Z. To View API error logs, navigate to API Error Logs
Error message: Error message
Stack Trace: stack trace

"); + verify(thymeLeafContext).setVariable("footer_address", "API Error Notification"); + verify(templateEngine).process("emails/email_template", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/FirmwareUpgradeFailureEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/FirmwareUpgradeFailureEmailGeneratorTest.java new file mode 100644 index 000000000..fe26826b3 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/FirmwareUpgradeFailureEmailGeneratorTest.java @@ -0,0 +1,111 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.FirmwareUpgradeFailureEmailContents; + +@ExtendWith(MockitoExtension.class) +class FirmwareUpgradeFailureEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private FirmwareUpgradeFailureEmailGenerator firmwareUpgradeFailureEmailGenerator; + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + FirmwareUpgradeFailureEmailGenerator snapshotGenerator = new FirmwareUpgradeFailureEmailGenerator( + springTemplateEngine, + unsubscribeTokenGenerator, emailProperties); + + FirmwareUpgradeFailureEmailContents contents = new FirmwareUpgradeFailureEmailContents(); + contents.setRsuIp("10.0.0.78"); + contents.setMessage("ConnectionError: SNMP timeout"); + contents.setFailureType("Firmware Upgrader"); + contents.setStackTrace( + "Traceback (most recent call last):\n File \"/usr/local/bin/rsu_firmware_upgrade.py\", line 23, in "); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/firmware_upgrade_failure_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + firmwareUpgradeFailureEmailGenerator = new FirmwareUpgradeFailureEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + firmwareUpgradeFailureEmailGenerator = spy(firmwareUpgradeFailureEmailGenerator); + + FirmwareUpgradeFailureEmailContents contents = new FirmwareUpgradeFailureEmailContents(); + contents.setRsuIp("10.0.0.78"); + contents.setMessage("ConnectionError: SNMP timeout"); + contents.setFailureType("Firmware Upgrader"); + contents.setStackTrace( + "Traceback (most recent call last):\n File \"/usr/local/bin/rsu_firmware_upgrade.py\", line 23, in "); + + Context thymeLeafContext = mock(Context.class); + + doCallRealMethod().when(firmwareUpgradeFailureEmailGenerator).generateEmailBody(any()); + + when(firmwareUpgradeFailureEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template_firmware_upgrade_failure", thymeLeafContext)) + .thenReturn("HTML CONTENT"); + + EmailContent result = firmwareUpgradeFailureEmailGenerator.generateEmailBody(contents); + + EmailContent expectedResult = new EmailContent("CV-Manager Firmware Upgrade Failure", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(6)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "Firmware Upgrade Failure in CV Manager"); + verify(thymeLeafContext).setVariable("footer_address", "CV-Manager Firmware Upgrade Failure"); + verify(thymeLeafContext).setVariable("rsu_ip", "10.0.0.78"); + verify(thymeLeafContext).setVariable("failure_type", "Firmware Upgrader"); + verify(thymeLeafContext).setVariable("error_message", "ConnectionError: SNMP timeout"); + verify(thymeLeafContext).setVariable("stack_trace", + "Traceback (most recent call last):
File "/usr/local/bin/rsu_firmware_upgrade.py", line 23, in <module>"); + verify(templateEngine).process("emails/email_template_firmware_upgrade_failure", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/IntersectionNotificationSummaryEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/IntersectionNotificationSummaryEmailGeneratorTest.java new file mode 100644 index 000000000..92ddc689f --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/IntersectionNotificationSummaryEmailGeneratorTest.java @@ -0,0 +1,122 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; +import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.Notification; +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.IntersectionNotificationSummaryEmailContents; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IntersectionNotificationSummaryEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private IntersectionNotificationSummaryEmailGenerator intersectionNotificationSummaryEmailGenerator; + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + this.templateEngine = springTemplateEngine; + + IntersectionNotificationSummaryEmailGenerator snapshotGenerator = new IntersectionNotificationSummaryEmailGenerator( + templateEngine, unsubscribeTokenGenerator, + emailProperties); + + Notification notification = new ConnectionOfTravelNotification(); + notification.setIntersectionID(0); + notification.setKey("connection-of-travel-notification"); + notification.setNotificationText("Test notification text with special characters: ' \" < > &"); + notification.setNotificationHeading("Test Notification Heading"); + notification.setNotificationGeneratedAt(1770830034000L); // 2025-02-11T10:30:34Z + + List notifications = List.of(notification); + + IntersectionNotificationSummaryEmailContents contents = new IntersectionNotificationSummaryEmailContents( + notifications); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/intersection_notification_summary_email_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + intersectionNotificationSummaryEmailGenerator = new IntersectionNotificationSummaryEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + intersectionNotificationSummaryEmailGenerator = spy(intersectionNotificationSummaryEmailGenerator); + + Context thymeLeafContext = mock(Context.class); + + Notification notification = new ConnectionOfTravelNotification(); + notification.setIntersectionID(0); + notification.setKey("connection-of-travel-notification"); + notification.setNotificationText("Test notification text with special characters: ' \" < > &"); + notification.setNotificationHeading("Test Notification Heading"); + notification.setNotificationGeneratedAt(1770830034000L); // 2025-02-11T10:30:34Z + + List notifications = List.of(notification); + IntersectionNotificationSummaryEmailContents data = new IntersectionNotificationSummaryEmailContents( + notifications); + + doCallRealMethod().when(intersectionNotificationSummaryEmailGenerator).generateEmailBody(any()); + + when(intersectionNotificationSummaryEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + when(intersectionNotificationSummaryEmailGenerator.getEmailText(notifications)) + .thenReturn("notification content"); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template", thymeLeafContext)).thenReturn("HTML CONTENT"); + + EmailContent result = intersectionNotificationSummaryEmailGenerator.generateEmailBody(data); + + EmailContent expectedResult = new EmailContent("New CV-Manager Intersection Notifications", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(3)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "New Notifications in CV Manager"); + verify(thymeLeafContext).setVariable("content_1", "

notification content

"); + verify(thymeLeafContext).setVariable("footer_address", "CV-Manager Automated Notifications"); + verify(templateEngine).process("emails/email_template", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/MessageCountEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/MessageCountEmailGeneratorTest.java new file mode 100644 index 000000000..2a8d76022 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/MessageCountEmailGeneratorTest.java @@ -0,0 +1,173 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountCountsItem; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountRsuItem; + +@ExtendWith(MockitoExtension.class) +class MessageCountEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private MessageCountEmailGenerator messageCountEmailGenerator; + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + MessageCountEmailGenerator snapshotGenerator = new MessageCountEmailGenerator(springTemplateEngine, + unsubscribeTokenGenerator, emailProperties); + + MessageCountEmailContents contents = new MessageCountEmailContents(); + contents.setOrganizationName("Test Org"); + contents.setDeploymentTitle("Dev"); + contents.setStartDate(Instant.ofEpochMilli(1776902059000L)); + contents.setEndDate(Instant.ofEpochMilli(1776905659000L)); + contents.setMessageTypeList(List.of("BSM", "MAP", "TIM")); + + MessageCountCountsItem bsmCounts = new MessageCountCountsItem(); + bsmCounts.setIn(1000); + bsmCounts.setOut(1000); + bsmCounts.setDiffPercent(0.0); + + MessageCountCountsItem mapCounts = new MessageCountCountsItem(); + mapCounts.setIn(2000); + mapCounts.setOut(1000); + mapCounts.setDiffPercent(6.0); + + MessageCountCountsItem timCounts = new MessageCountCountsItem(); + timCounts.setIn(2000); + timCounts.setOut(1000); + timCounts.setDiffPercent(3.0); + + Map countsByType = Map.of( + "BSM", bsmCounts, + "MAP", mapCounts, + "TIM", timCounts + ); + + MessageCountRsuItem rsuCountsItem = new MessageCountRsuItem(); + rsuCountsItem.setRsuIp("0.0.0.1"); + rsuCountsItem.setPrimaryRoute("route"); + rsuCountsItem.setMessageCountsByType(countsByType); + + contents.setRsuCounts(List.of(rsuCountsItem)); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/message_count_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + messageCountEmailGenerator = new MessageCountEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + messageCountEmailGenerator = spy(messageCountEmailGenerator); + + + + MessageCountEmailContents contents = new MessageCountEmailContents(); + contents.setOrganizationName("Test Org"); + contents.setDeploymentTitle("Dev"); + contents.setStartDate(Instant.ofEpochMilli(1776902059000L)); + contents.setEndDate(Instant.ofEpochMilli(1776905659000L)); + contents.setMessageTypeList(List.of("BSM", "MAP", "TIM")); + + MessageCountCountsItem bsmCounts = new MessageCountCountsItem(); + bsmCounts.setIn(1000); + bsmCounts.setOut(1000); + bsmCounts.setDiffPercent(0.0); + + MessageCountCountsItem mapCounts = new MessageCountCountsItem(); + mapCounts.setIn(2000); + mapCounts.setOut(1000); + mapCounts.setDiffPercent(6.0); + + MessageCountCountsItem timCounts = new MessageCountCountsItem(); + timCounts.setIn(2000); + timCounts.setOut(1000); + timCounts.setDiffPercent(3.0); + + Map countsByType = Map.of( + "BSM", bsmCounts, + "MAP", mapCounts, + "TIM", timCounts + ); + + MessageCountRsuItem rsuCountsItem = new MessageCountRsuItem(); + rsuCountsItem.setRsuIp("0.0.0.1"); + rsuCountsItem.setPrimaryRoute("route"); + rsuCountsItem.setMessageCountsByType(countsByType); + + contents.setRsuCounts(List.of(rsuCountsItem)); + + Context thymeLeafContext = mock(Context.class); + + doCallRealMethod().when(messageCountEmailGenerator).generateEmailBody(any()); + + when(messageCountEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template_message_counts", thymeLeafContext)).thenReturn("HTML CONTENT"); + + EmailContent result = messageCountEmailGenerator.generateEmailBody(contents); + + EmailContent expectedResult = new EmailContent("CDOT-CV Dev ODE Counts", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(8)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "Message Counts from CV Manager"); + verify(thymeLeafContext).setVariable("footer_address", "CV-Manager Message Counts"); + verify(thymeLeafContext).setVariable("organizationName", "Test Org"); + verify(thymeLeafContext).setVariable("deploymentTitle", "Dev"); + verify(thymeLeafContext).setVariable("startDate", "2026-04-22T23:54:19Z"); + verify(thymeLeafContext).setVariable("endDate", "2026-04-23T00:54:19Z"); + verify(thymeLeafContext).setVariable("messageTypes", List.of("BSM", "MAP", "TIM")); + verify(thymeLeafContext).setVariable("messageCounts", List.of(rsuCountsItem)); + verify(templateEngine).process("emails/email_template_message_counts", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/RsuErrorSummaryEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/RsuErrorSummaryEmailGeneratorTest.java new file mode 100644 index 000000000..31c043716 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/RsuErrorSummaryEmailGeneratorTest.java @@ -0,0 +1,107 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.RsuErrorSummaryEmailContents; + +@ExtendWith(MockitoExtension.class) +class RsuErrorSummaryEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private RsuErrorSummaryEmailGenerator rsuErrorSummaryEmailGenerator; + + @BeforeEach + void setUp() { + } + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + RsuErrorSummaryEmailGenerator snapshotGenerator = new RsuErrorSummaryEmailGenerator(springTemplateEngine, + unsubscribeTokenGenerator, emailProperties); + + String subject = "RSU Error Summary for RSU 192.168.1.1"; + String message = """ + Summary of RSU errors:\n- RSU 192.168.1.1: Connection timeout\n- RSU 192.168.1.2: Authentication failed + + And a message with 'quotes', \"double quotes\", and tags & ampersands + """; + + RsuErrorSummaryEmailContents contents = new RsuErrorSummaryEmailContents(subject, message); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/rsu_error_summary_email_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + rsuErrorSummaryEmailGenerator = new RsuErrorSummaryEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + rsuErrorSummaryEmailGenerator = spy(rsuErrorSummaryEmailGenerator); + + Context thymeLeafContext = mock(Context.class); + RsuErrorSummaryEmailContents data = new RsuErrorSummaryEmailContents("subject", "message"); + + doCallRealMethod().when(rsuErrorSummaryEmailGenerator).generateEmailBody(any()); + + when(rsuErrorSummaryEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template", thymeLeafContext)).thenReturn("HTML CONTENT"); + + EmailContent result = rsuErrorSummaryEmailGenerator.generateEmailBody(data); + + EmailContent expectedResult = new EmailContent("subject", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(4)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "RSU Error Summary from CV Manager"); + verify(thymeLeafContext).setVariable("content_1", "message"); + verify(thymeLeafContext).setVariable("footer_address", "RSU Error Summary Report"); + verify(thymeLeafContext).setVariable("showUnsubscribeLink", false); + verify(templateEngine).process("emails/email_template", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/SupportRequestEmailGeneratorTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/SupportRequestEmailGeneratorTest.java new file mode 100644 index 000000000..17304eb01 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/generators/SupportRequestEmailGeneratorTest.java @@ -0,0 +1,109 @@ +package us.dot.its.jpo.ode.api.emails.generators; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import us.dot.its.jpo.ode.api.SnapshotTestUtils; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.contents.SupportRequestEmailContents; + +@ExtendWith(MockitoExtension.class) +class SupportRequestEmailGeneratorTest { + + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + + @Mock + private EmailProperties emailProperties; + + @Mock + private TemplateEngine templateEngine; + + private SupportRequestEmailGenerator supportRequestEmailGenerator; + + @Test + void testGenerateEmailBody_SnapshotTest() throws IOException { + when(emailProperties.getCvmgrFrontEndUri()).thenReturn("https://cvmanager.com"); + + // Configure the template resolver + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + templateResolver.setPrefix("templates/"); // Path to your templates directory + templateResolver.setSuffix(".html"); // Template file extension + templateResolver.setTemplateMode("HTML"); + templateResolver.setCharacterEncoding("UTF-8"); + + // Configure the SpringTemplateEngine + SpringTemplateEngine springTemplateEngine = new SpringTemplateEngine(); + springTemplateEngine.setTemplateResolver(templateResolver); + + SupportRequestEmailGenerator snapshotGenerator = new SupportRequestEmailGenerator(springTemplateEngine, + unsubscribeTokenGenerator, emailProperties); + + String subject = "Support Request: System Issues"; + String email = "admin@example.com"; + String message = """ + I'm experiencing multiple issues: + + 1. Cannot access dashboard + 2. Reports are not loading + 3. Email notifications not working + + Please investigate as soon as possible. + + And a message with 'quotes', \"double quotes\", and tags & ampersands + """; + + SupportRequestEmailContents contents = new SupportRequestEmailContents(email, subject, message); + + EmailContent result = snapshotGenerator.generateEmailBody(contents); + + String snapshotPath = "emails/support_request_email_multiline_snapshot.html"; + SnapshotTestUtils.assertMatchesSnapshot(result.getBody(), snapshotPath); + } + + @Test + void generateEmailBody_mockedTest() { + supportRequestEmailGenerator = new SupportRequestEmailGenerator( + templateEngine, + unsubscribeTokenGenerator, + emailProperties); + supportRequestEmailGenerator = spy(supportRequestEmailGenerator); + + Context thymeLeafContext = mock(Context.class); + SupportRequestEmailContents data = new SupportRequestEmailContents("admin@example.com", "subject", "message"); + + doCallRealMethod().when(supportRequestEmailGenerator).generateEmailBody(any()); + + when(supportRequestEmailGenerator.generateEmailContextBasic()).thenReturn(thymeLeafContext); + doNothing().when(thymeLeafContext).setVariable(anyString(), any()); + + when(templateEngine.process("emails/email_template", thymeLeafContext)).thenReturn("HTML CONTENT"); + + EmailContent result = supportRequestEmailGenerator.generateEmailBody(data); + + EmailContent expectedResult = new EmailContent("subject", "HTML CONTENT"); + assertEquals(expectedResult, result); + + verify(thymeLeafContext, times(3)).setVariable(anyString(), any()); + verify(thymeLeafContext).setVariable("preview_text", "New Support Request in CV Manager"); + verify(thymeLeafContext).setVariable("content_1", + "

New support request from admin@example.com:

message

"); + verify(thymeLeafContext).setVariable("footer_address", "CV-Manager User-Submitted Support Request"); + verify(templateEngine).process("emails/email_template", thymeLeafContext); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderPostmarkTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderPostmarkTest.java new file mode 100644 index 000000000..4f840c186 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderPostmarkTest.java @@ -0,0 +1,108 @@ +package us.dot.its.jpo.ode.api.emails.providers; + +import com.postmarkapp.postmark.client.ApiClient; +import com.postmarkapp.postmark.client.data.model.message.MessageResponse; +import com.postmarkapp.postmark.client.exception.PostmarkException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.EmailRecipient; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class EmailProviderPostmarkTest { + + @Mock + private EmailProperties emailProperties; + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + @Mock + private ApiClient postmark; + + @InjectMocks + private EmailProviderPostmark provider; + + private EmailRecipient recipient; + private EmailContent content; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(emailProperties.getSenderAddress()).thenReturn("sender@example.com"); + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn("http://unsubscribe"); + recipient = new EmailRecipient("to@example.com", null); + content = new EmailContent("subject", "body with {{unsubscribe_url}}"); + } + + @Test + void testSendBatchedEmailsSuccess() throws Exception { + MessageResponse resp1 = new MessageResponse(); + resp1.setErrorCode(0); + resp1.setMessage("OK1"); + MessageResponse resp2 = new MessageResponse(); + resp2.setErrorCode(0); + resp2.setMessage("OK2"); + when(postmark.deliverMessage(anyList())).thenReturn(List.of(resp1, resp2)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals("OK1", results.get(0).getMessage()); + assertEquals("OK2", results.get(1).getMessage()); + verify(postmark, times(1)).deliverMessage(anyList()); + } + + @Test + void testSendBatchedEmailsThrowsPostmarkException() throws Exception { + when(postmark.deliverMessage(anyList())) + .thenThrow(new PostmarkException("fail", 500)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Internal Server Error", results.get(0).getMessage()); + verify(postmark, times(1)).deliverMessage(anyList()); + } + + @Test + void testSendBatchedEmailsThrowsIOException() throws Exception { + when(postmark.deliverMessage(anyList())) + .thenThrow(new IOException("fail")); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Internal Server Error", results.get(0).getMessage()); + verify(postmark, times(1)).deliverMessage(anyList()); + } + + @Test + void testReplacePlaceholders() throws Exception { + // Use reflection to access the private method + var method = provider.getClass().getDeclaredMethod("replacePlaceholders", String.class, String.class); + method.setAccessible(true); + + String input = "Click here: {{unsubscribe_url}}\nThank you!"; + String unsubUrl = "http://unsubscribe"; + String result = (String) method.invoke(provider, input, unsubUrl); + + assertTrue(result.contains(unsubUrl)); + assertTrue(result.contains("
")); + assertFalse(result.contains("{{unsubscribe_url}}")); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSendGridTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSendGridTest.java new file mode 100644 index 000000000..bd15d5032 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSendGridTest.java @@ -0,0 +1,156 @@ +package us.dot.its.jpo.ode.api.emails.providers; + +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.objects.Email; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.EmailRecipient; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class EmailProviderSendGridTest { + + @Mock + private EmailProperties emailProperties; + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + @Mock + private SendGrid sendGrid; + + @InjectMocks + private EmailProviderSendGrid provider; + + private EmailRecipient recipient; + private EmailContent content; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(emailProperties.getSenderAddress()).thenReturn("sender@example.com"); + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn("http://unsubscribe"); + + recipient = new EmailRecipient("to@example.com", null); + content = new EmailContent("subject", "body with {{unsubscribe_url}}"); + } + + @Test + void testSendBatchedEmailsSuccess() throws IOException { + Response response = new Response(); + response.setStatusCode(200); + response.setBody("OK"); + when(sendGrid.api(any())).thenReturn(response); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(200, results.get(0).getStatusCode()); + assertEquals("OK", results.get(0).getMessage()); + verify(sendGrid, times(1)).api(any()); + } + + @Test + void testSendBatchedEmailsThrowsIOException() throws IOException { + when(sendGrid.api(any())).thenThrow(new IOException("Network error")); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Internal Server Error", results.get(0).getMessage()); + assertEquals(500, results.get(1).getStatusCode()); + assertEquals("Internal Server Error", results.get(1).getMessage()); + verify(sendGrid, times(1)).api(any()); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlGenerationFails() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())) + .thenThrow(new RuntimeException("Token generation failed")); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Failed to generate unsubscribe URL", results.get(0).getMessage()); + assertEquals(500, results.get(1).getStatusCode()); + assertEquals("Failed to generate unsubscribe URL", results.get(1).getMessage()); + verifyNoInteractions(sendGrid); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlReturnsNull() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn(null); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertTrue(results.get(0).getMessage().contains("Failed to generate unsubscribe URL")); + verifyNoInteractions(sendGrid); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlReturnsEmpty() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn(""); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertTrue(results.get(0).getMessage().contains("Failed to generate unsubscribe URL")); + verifyNoInteractions(sendGrid); + } + + @Test + void testSendBatchedEmailsNonSuccessStatusCode() throws IOException { + Response response = new Response(); + response.setStatusCode(400); + response.setBody("Bad Request"); + when(sendGrid.api(any())).thenReturn(response); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(400, results.get(0).getStatusCode()); + assertEquals("Bad Request", results.get(0).getMessage()); + verify(sendGrid, times(1)).api(any()); + } + + @Test + void testEmailRecipientToSendGridEmail() { + EmailRecipient testRecipient = new EmailRecipient("test@example.com", "Test Name"); + Email sendGridEmail = testRecipient.toSendGridEmail(); + + assertNotNull(sendGridEmail); + assertEquals("test@example.com", sendGridEmail.getEmail()); + assertEquals("Test Name", sendGridEmail.getName()); + } + + @Test + void testEmailRecipientToSendGridEmailWithoutName() { + EmailRecipient testRecipient = new EmailRecipient("test@example.com", null); + Email sendGridEmail = testRecipient.toSendGridEmail(); + + assertNotNull(sendGridEmail); + assertEquals("test@example.com", sendGridEmail.getEmail()); + assertNull(sendGridEmail.getName()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSmtpTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSmtpTest.java new file mode 100644 index 000000000..bf0d06eb9 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/emails/providers/EmailProviderSmtpTest.java @@ -0,0 +1,167 @@ +package us.dot.its.jpo.ode.api.emails.providers; + +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.mail.MailAuthenticationException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import us.dot.its.jpo.ode.api.emails.EmailProperties; +import us.dot.its.jpo.ode.api.emails.UnsubscribeTokenGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.EmailRecipient; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; + +import java.util.List; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class EmailProviderSmtpTest { + + @Mock + private EmailProperties emailProperties; + @Mock + private UnsubscribeTokenGenerator unsubscribeTokenGenerator; + @Mock + private JavaMailSender mailSender; + + @InjectMocks + private EmailProviderSmtp provider; + + private EmailRecipient recipient; + private EmailContent content; + private MimeMessage mimeMessage; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(emailProperties.getSenderAddress()).thenReturn("sender@example.com"); + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn("http://unsubscribe"); + + // Create a real MimeMessage with a mock session + Session session = Session.getInstance(new Properties()); + mimeMessage = new MimeMessage(session); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + recipient = new EmailRecipient("to@example.com", null); + content = new EmailContent("subject", "body with {{unsubscribe_url}}"); + } + + @Test + void testSendBatchedEmailsSuccess() { + doNothing().when(mailSender).send(any(MimeMessage[].class)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(200, results.get(0).getStatusCode()); + assertTrue(results.get(0).getMessage().contains("2")); + verify(mailSender, times(1)).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsMailAuthenticationException() { + doThrow(new MailAuthenticationException("Auth failed")) + .when(mailSender).send(any(MimeMessage[].class)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("SMTP authentication failed", results.get(0).getMessage()); + verify(mailSender, times(1)).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsMailSendException() { + doThrow(new MailSendException("Send failed")) + .when(mailSender).send(any(MimeMessage[].class)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Failed to send batch emails", results.get(0).getMessage()); + verify(mailSender, times(1)).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsUnknownException() { + doThrow(new RuntimeException("Unknown error")) + .when(mailSender).send(any(MimeMessage[].class)); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(1, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Unknown error", results.get(0).getMessage()); + verify(mailSender, times(1)).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlGenerationFails() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())) + .thenThrow(new RuntimeException("Token generation failed")); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertEquals("Failed to generate unsubscribe URL", results.get(0).getMessage()); + assertEquals(500, results.get(1).getStatusCode()); + assertEquals("Failed to generate unsubscribe URL", results.get(1).getMessage()); + verify(mailSender, never()).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlReturnsNull() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn(null); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertTrue(results.get(0).getMessage().contains("Failed to generate unsubscribe URL")); + verify(mailSender, never()).send(any(MimeMessage[].class)); + } + + @Test + void testSendBatchedEmailsUnsubscribeUrlReturnsEmpty() { + when(unsubscribeTokenGenerator.generateUnsubscribeUrl(anyString())).thenReturn(""); + + List results = provider.sendBatchedEmails( + List.of(recipient, new EmailRecipient("to2@example.com", null)), content); + + assertEquals(2, results.size()); + assertEquals(500, results.get(0).getStatusCode()); + assertTrue(results.get(0).getMessage().contains("Failed to generate unsubscribe URL")); + verify(mailSender, never()).send(any(MimeMessage[].class)); + } + + @Test + void testReplacePlaceholders() throws Exception { + // Use reflection to access the private method + var method = provider.getClass().getDeclaredMethod("replacePlaceholders", String.class, String.class); + method.setAccessible(true); + + String input = "Click here: {{unsubscribe_url}} to unsubscribe"; + String unsubUrl = "http://unsubscribe"; + String result = (String) method.invoke(provider, input, unsubUrl); + + assertTrue(result.contains(unsubUrl)); + assertFalse(result.contains("{{unsubscribe_url}}")); + assertEquals("Click here: http://unsubscribe to unsubscribe", result); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/fixtures/TestFixtures.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/fixtures/TestFixtures.java new file mode 100644 index 000000000..c1a90573c --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/fixtures/TestFixtures.java @@ -0,0 +1,118 @@ +package us.dot.its.jpo.ode.api.fixtures; + +import com.github.javafaker.Faker; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; +import us.dot.its.jpo.ode.api.models.postgres.tables.*; +import java.net.InetAddress; +import java.net.UnknownHostException; + +public class TestFixtures { + + private final Faker faker = new Faker(); + private static final GeometryFactory GF = new GeometryFactory(new PrecisionModel(), 4326); + + public Point createPoint(double lon, double lat) { + return GF.createPoint(new Coordinate(lon, lat)); + } + + public Organization createOrg(String name) { + Organization org = new Organization(); + org.setName(name); + return org; + } + + public Organization createRandomOrg() { + return createOrg(faker.company().name() + "-" + faker.crypto().md5().substring(0, 8)); + } + + public Intersection createIntersection(String number) { + Intersection i = new Intersection(); + i.setIntersectionNumber(number); + i.setRefPt(createPoint(-105.0, 40.0)); + return i; + } + + public IntersectionOrganization createIntersectionOrganization(Intersection intersection, Organization org) { + IntersectionOrganization io = new IntersectionOrganization(); + io.setIntersection(intersection); + io.setOrganization(org); + return io; + } + + public Manufacturer createRandomManufacturer() { + Manufacturer mfr = new Manufacturer(); + mfr.setName(faker.company().name()); + return mfr; + } + + public RsuModel createRandomRsuModel(Manufacturer manufacturer) { + RsuModel model = new RsuModel(); + model.setName("Model-" + faker.code().asin()); + model.setSupportedRadio("DSRC"); + model.setManufacturer(manufacturer); + return model; + } + + public RsuCredential createRandomRsuCredential(Organization owner) { + RsuCredential cred = new RsuCredential(); + cred.setUsername(faker.name().username()); + cred.setPassword(faker.internet().password()); + cred.setNickname("cred-" + faker.lorem().word() + faker.number().digits(3)); + cred.setOwnerOrganization(owner); + return cred; + } + + public SnmpCredential createRandomSnmpCredential(Organization owner) { + SnmpCredential snmpCred = new SnmpCredential(); + snmpCred.setUsername("snmp-" + faker.name().username()); + snmpCred.setPassword(faker.internet().password()); + snmpCred.setNickname("snmp-" + faker.lorem().word() + faker.number().digits(3)); + snmpCred.setOwnerOrganization(owner); + return snmpCred; + } + + public SnmpProtocol createRandomSnmpProtocol() { + SnmpProtocol proto = new SnmpProtocol(); + proto.setProtocolCode("NTCIP1218"); + proto.setNickname("NTCIP-" + faker.lorem().word() + faker.number().digits(3)); + return proto; + } + + public Rsu createRsu(String ip, RsuModel model, RsuCredential cred, SnmpCredential snmpCred, SnmpProtocol proto) throws UnknownHostException { + Rsu rsu = new Rsu(); + rsu.setIpv4Address(InetAddress.getByName(ip)); + rsu.setGeography(createPoint(-105.0, 40.0)); + rsu.setMilepost(faker.number().randomDouble(2, 0, 1000)); + rsu.setSerialNumber("SN-" + faker.crypto().md5().substring(0, 8)); + rsu.setIssScmsId("ISS-" + faker.crypto().md5().substring(0, 8)); + rsu.setPrimaryRoute(faker.address().streetName()); + rsu.setModel(model); + rsu.setCredential(cred); + rsu.setSnmpCredential(snmpCred); + rsu.setSnmpProtocol(proto); + return rsu; + } + + public RsuIntersection createRsuIntersection(Rsu rsu, Intersection intersection) { + RsuIntersection ri = new RsuIntersection(); + ri.setRsu(rsu); + ri.setIntersection(intersection); + return ri; + } + + public RsuOrganization createRsuOrganization(Rsu rsu, Organization org) { + RsuOrganization ro = new RsuOrganization(); + ro.setRsu(rsu); + ro.setOrganization(org); + return ro; + } + + public Polygon createBBox(double minLat, double minLon, double maxLat, double maxLon) { + return (Polygon) GF.toGeometry(new Envelope(minLon, maxLon, minLat, maxLat)); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/GeometryMapperTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/GeometryMapperTest.java new file mode 100644 index 000000000..b0eace94e --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/GeometryMapperTest.java @@ -0,0 +1,306 @@ +package us.dot.its.jpo.ode.api.mappers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; + +import us.dot.its.jpo.ode.api.models.admin.intersection.Bbox; +import us.dot.its.jpo.ode.api.models.admin.intersection.RefPt; + +import static org.junit.jupiter.api.Assertions.*; + +class GeometryMapperTest { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326); + private static final double DELTA = 0.0000001; + + private GeometryMapper mapper; + + @BeforeEach + void setUp() { + mapper = new GeometryMapperImpl(); + } + + @Nested + @DisplayName("toRefPt(Point) — JTS Point to RefPt DTO") + class ToRefPtTests { + + @Test + @DisplayName("null input returns null") + void toRefPt_null_returnsNull() { + assertNull(mapper.toRefPt(null)); + } + + @Test + @DisplayName("valid Point maps x→longitude, y→latitude") + void toRefPt_validPoint_mapsCoordinates() { + double latitude = 39.7392; + double longitude = -104.9903; + Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude)); + + RefPt result = mapper.toRefPt(point); + + assertNotNull(result); + assertEquals(latitude, result.getLatitude(), DELTA); + assertEquals(longitude, result.getLongitude(), DELTA); + } + + @Test + @DisplayName("southern-hemisphere point preserves negative latitude") + void toRefPt_negativeLatitude_preserved() { + double latitude = -33.8688; + double longitude = 151.2093; + Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude)); + + RefPt result = mapper.toRefPt(point); + + assertNotNull(result); + assertEquals(latitude, result.getLatitude(), DELTA); + assertEquals(longitude, result.getLongitude(), DELTA); + } + + @Test + @DisplayName("western-hemisphere point preserves negative longitude") + void toRefPt_negativeLongitude_preserved() { + double latitude = -34.6037; + double longitude = -58.3816; + Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude)); + + RefPt result = mapper.toRefPt(point); + + assertNotNull(result); + assertEquals(latitude, result.getLatitude(), DELTA); + assertEquals(longitude, result.getLongitude(), DELTA); + } + } + + @Nested + @DisplayName("toPoint(RefPt) — RefPt DTO to JTS Point") + class ToPointTests { + + @Test + @DisplayName("null input returns null") + void toPoint_null_returnsNull() { + assertNull(mapper.toPoint(null)); + } + + @Test + @DisplayName("valid RefPt maps latitude→y, longitude→x with SRID 4326") + void toPoint_validRefPt_mapsCoordinatesAndSrid() { + double latitude = 39.7392; + double longitude = -104.9903; + RefPt refPt = new RefPt(latitude, longitude); + + Point result = mapper.toPoint(refPt); + + assertNotNull(result); + assertEquals(longitude, result.getX(), DELTA); + assertEquals(latitude, result.getY(), DELTA); + assertEquals(4326, result.getSRID()); + } + + @Test + @DisplayName("southern/western RefPt preserves negative coordinates") + void toPoint_negativeCoords_preserved() { + RefPt refPt = new RefPt(-33.8688, -58.3816); + + Point result = mapper.toPoint(refPt); + + assertNotNull(result); + assertEquals(-58.3816, result.getX(), DELTA); + assertEquals(-33.8688, result.getY(), DELTA); + } + } + + @Nested + @DisplayName("toBbox(Polygon) — JTS Polygon to Bbox DTO") + class ToBboxTests { + + @Test + @DisplayName("null input returns null") + void toBbox_null_returnsNull() { + assertNull(mapper.toBbox(null)); + } + + @Test + @DisplayName("rectangular polygon maps envelope to Bbox corners") + void toBbox_rectangularPolygon_mapsEnvelope() { + double minLat = 39.73; + double minLon = -105.00; + double maxLat = 39.74; + double maxLon = -104.99; + Polygon polygon = (Polygon) GEOMETRY_FACTORY.toGeometry( + new Envelope(minLon, maxLon, minLat, maxLat)); + + Bbox result = mapper.toBbox(polygon); + + assertNotNull(result); + assertEquals(minLat, result.getLatitude1(), DELTA); + assertEquals(minLon, result.getLongitude1(), DELTA); + assertEquals(maxLat, result.getLatitude2(), DELTA); + assertEquals(maxLon, result.getLongitude2(), DELTA); + } + + @Test + @DisplayName("polygon spanning the equator maps negative and positive latitudes") + void toBbox_crossesEquator_preservesSigns() { + double minLat = -1.0; + double maxLat = 1.0; + double minLon = -1.0; + double maxLon = 1.0; + Polygon polygon = (Polygon) GEOMETRY_FACTORY.toGeometry( + new Envelope(minLon, maxLon, minLat, maxLat)); + + Bbox result = mapper.toBbox(polygon); + + assertNotNull(result); + assertEquals(minLat, result.getLatitude1(), DELTA); + assertEquals(minLon, result.getLongitude1(), DELTA); + assertEquals(maxLat, result.getLatitude2(), DELTA); + assertEquals(maxLon, result.getLongitude2(), DELTA); + } + } + + @Nested + @DisplayName("toPolygon(Bbox) — Bbox DTO to JTS Polygon") + class ToPolygonTests { + + @Test + @DisplayName("null input returns null") + void toPolygon_null_returnsNull() { + assertNull(mapper.toPolygon(null)); + } + + @Test + @DisplayName("valid Bbox produces polygon with correct envelope and SRID 4326") + void toPolygon_validBbox_correctEnvelopeAndSrid() { + double lat1 = 39.73; + double lon1 = -105.00; + double lat2 = 39.74; + double lon2 = -104.99; + Bbox bbox = new Bbox(lat1, lon1, lat2, lon2); + + Polygon result = mapper.toPolygon(bbox); + + assertNotNull(result); + Envelope env = result.getEnvelopeInternal(); + assertEquals(lon1, env.getMinX(), DELTA); + assertEquals(lon2, env.getMaxX(), DELTA); + assertEquals(lat1, env.getMinY(), DELTA); + assertEquals(lat2, env.getMaxY(), DELTA); + assertEquals(4326, result.getSRID()); + } + + @Test + @DisplayName("Bbox spanning the antimeridian preserves negative longitude") + void toPolygon_negativeLongitude_preserved() { + Bbox bbox = new Bbox(-1.0, -180.0, 1.0, -179.0); + + Polygon result = mapper.toPolygon(bbox); + + assertNotNull(result); + Envelope env = result.getEnvelopeInternal(); + assertEquals(-180.0, env.getMinX(), DELTA); + assertEquals(-179.0, env.getMaxX(), DELTA); + } + } + + @Nested + @DisplayName("Round-trip conversion tests") + class RoundTripTests { + + @Test + @DisplayName("Point → RefPt → Point preserves coordinates and SRID") + void roundTrip_pointToRefPtToPoint() { + double latitude = 39.7392; + double longitude = -104.9903; + Point original = GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude)); + + RefPt refPt = mapper.toRefPt(original); + Point result = mapper.toPoint(refPt); + + assertNotNull(result); + assertEquals(original.getX(), result.getX(), DELTA); + assertEquals(original.getY(), result.getY(), DELTA); + assertEquals(4326, result.getSRID()); + } + + @Test + @DisplayName("RefPt → Point → RefPt preserves latitude and longitude") + void roundTrip_refPtToPointToRefPt() { + RefPt original = new RefPt(39.7392, -104.9903); + + Point point = mapper.toPoint(original); + RefPt result = mapper.toRefPt(point); + + assertNotNull(result); + assertEquals(original.getLatitude(), result.getLatitude(), DELTA); + assertEquals(original.getLongitude(), result.getLongitude(), DELTA); + } + + @Test + @DisplayName("Polygon → Bbox → Polygon preserves envelope bounds") + void roundTrip_polygonToBboxToPolygon() { + Envelope original = new Envelope(-105.00, -104.99, 39.73, 39.74); + Polygon originalPolygon = (Polygon) GEOMETRY_FACTORY.toGeometry(original); + + Bbox bbox = mapper.toBbox(originalPolygon); + Polygon result = mapper.toPolygon(bbox); + + assertNotNull(result); + Envelope resultEnv = result.getEnvelopeInternal(); + assertEquals(original.getMinX(), resultEnv.getMinX(), DELTA); + assertEquals(original.getMaxX(), resultEnv.getMaxX(), DELTA); + assertEquals(original.getMinY(), resultEnv.getMinY(), DELTA); + assertEquals(original.getMaxY(), resultEnv.getMaxY(), DELTA); + } + + @Test + @DisplayName("Bbox → Polygon → Bbox preserves all four corners") + void roundTrip_bboxToPolygonToBbox() { + Bbox original = new Bbox(39.73, -105.00, 39.74, -104.99); + + Polygon polygon = mapper.toPolygon(original); + Bbox result = mapper.toBbox(polygon); + + assertNotNull(result); + assertEquals(original.getLatitude1(), result.getLatitude1(), DELTA); + assertEquals(original.getLongitude1(), result.getLongitude1(), DELTA); + assertEquals(original.getLatitude2(), result.getLatitude2(), DELTA); + assertEquals(original.getLongitude2(), result.getLongitude2(), DELTA); + } + + @Test + @DisplayName("round-trip holds across multiple geographic locations") + void roundTrip_multipleLocations() { + double[][] locations = { + { 39.7392, -104.9903 }, // Denver, CO + { 35.6762, 139.6503 }, // Tokyo, Japan + { -33.8688, 151.2093 }, // Sydney, Australia + { -34.6037, -58.3816 }, // Buenos Aires, Argentina + { 51.5074, -0.1278 } // London, UK + }; + + for (double[] coords : locations) { + double latitude = coords[0]; + double longitude = coords[1]; + RefPt original = new RefPt(latitude, longitude); + + Point point = mapper.toPoint(original); + RefPt result = mapper.toRefPt(point); + + assertEquals(latitude, result.getLatitude(), DELTA, + "Latitude mismatch for (" + latitude + ", " + longitude + ")"); + assertEquals(longitude, result.getLongitude(), DELTA, + "Longitude mismatch for (" + latitude + ", " + longitude + ")"); + } + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/INetMapperTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/INetMapperTest.java new file mode 100644 index 000000000..0e1ec17e0 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/INetMapperTest.java @@ -0,0 +1,96 @@ +package us.dot.its.jpo.ode.api.mappers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import static org.junit.jupiter.api.Assertions.*; + +class INetMapperTest { + + private INetMapper iNetMapper; + + @BeforeEach + void setUp() { + iNetMapper = new INetMapperImpl(); + } + + @Nested + @DisplayName("Tests for mapInetAddressToString mapper method") + class MapInetAddressToStringTests { + @Test + void testMapInetAddressToString_ValidIpv4() throws UnknownHostException { + InetAddress address = InetAddress.getByName("192.168.1.100"); + + String result = iNetMapper.mapInetAddressToString(address); + + assertNotNull(result); + assertEquals("192.168.1.100", result); + } + + @Test + void testMapInetAddressToString_Null() { + String result = iNetMapper.mapInetAddressToString(null); + + assertNull(result); + } + + @Test + void testMapInetAddressToString_Localhost() throws UnknownHostException { + InetAddress address = InetAddress.getByName("127.0.0.1"); + + String result = iNetMapper.mapInetAddressToString(address); + + assertNotNull(result); + assertEquals("127.0.0.1", result); + } + } + + @Nested + @DisplayName("Tests for mapStringToInetAddress mapper method") + class MapStringToInetAddressTests { + @Test + void testMapStringToInetAddress_ValidIpv4() { + InetAddress result = iNetMapper.mapStringToInetAddress("192.168.1.100"); + + assertNotNull(result); + assertEquals("192.168.1.100", result.getHostAddress()); + } + + @Test + void testMapStringToInetAddress_Null() { + InetAddress result = iNetMapper.mapStringToInetAddress(null); + + assertNull(result); + } + + @Test + void testMapStringToInetAddress_InvalidIp() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> iNetMapper.mapStringToInetAddress("invalid-ip")); + + assertTrue(exception.getMessage().contains("Invalid IP address")); + assertTrue(exception.getMessage().contains("invalid-ip")); + } + } + + @Nested + @DisplayName("Round-trip tests for InetAddress <-> String mapping") + class RoundTripTests { + @Test + void testRoundTrip_ValidIpv4() { + String originalIp = "192.168.1.100"; + + InetAddress inetAddress = iNetMapper.mapStringToInetAddress(originalIp); + String resultIp = iNetMapper.mapInetAddressToString(inetAddress); + + assertNotNull(resultIp); + assertEquals(originalIp, resultIp); + } + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/IntersectionMapperTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/IntersectionMapperTest.java new file mode 100644 index 000000000..5eba07867 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/IntersectionMapperTest.java @@ -0,0 +1,306 @@ +package us.dot.its.jpo.ode.api.mappers; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import us.dot.its.jpo.ode.api.fixtures.TestFixtures; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionDto; +import us.dot.its.jpo.ode.api.models.postgres.tables.Intersection; +import us.dot.its.jpo.ode.api.models.postgres.tables.IntersectionOrganization; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@Import({ IntersectionMapperImpl.class, GeometryMapperImpl.class, INetMapperImpl.class }) +class IntersectionMapperTest { + + private static final double DELTA = 0.0000001; + private final TestFixtures testFixtures = new TestFixtures(); + + @Autowired + private IntersectionMapper mapper; + + private IntersectionOrganization orgEntry(String name) { + Organization org = new Organization(); + org.setName(name); + IntersectionOrganization io = new IntersectionOrganization(); + io.setOrganization(org); + return io; + } + + private IntersectionOrganization orgEntryNullOrg() { + IntersectionOrganization io = new IntersectionOrganization(); + io.setOrganization(null); + return io; + } + + private IntersectionOrganization orgEntryNullName() { + Organization org = new Organization(); + org.setName(null); + IntersectionOrganization io = new IntersectionOrganization(); + io.setOrganization(org); + return io; + } + + @Nested + @DisplayName("toDto(Intersection) — entity to DTO mapping") + class ToDtoTests { + + @Test + @DisplayName("null input returns null") + void toDto_null_returnsNull() { + assertNull(mapper.toDto(null)); + } + + @Test + @DisplayName("full entity maps all fields correctly") + void toDto_fullEntity_mapsAllFields() throws UnknownHostException { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setIntersectionName("Main St & 1st Ave"); + entity.setRefPt(testFixtures.createPoint(-104.9903, 39.7392)); + entity.setBbox(testFixtures.createBBox(39.73, -105.00, 39.74, -104.99)); + entity.setOriginIp(InetAddress.getByName("192.168.1.1")); + entity.setIntersectionOrganizations(List.of(orgEntry("CDOT"), orgEntry("Denver"))); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertEquals(12109, result.getIntersectionId()); + assertEquals("Main St & 1st Ave", result.getIntersectionName()); + assertEquals("192.168.1.1", result.getOriginIp()); + + assertNotNull(result.getRefPt()); + assertEquals(39.7392, result.getRefPt().getLatitude(), DELTA); + assertEquals(-104.9903, result.getRefPt().getLongitude(), DELTA); + + assertNotNull(result.getBbox()); + assertEquals(39.73, result.getBbox().getLatitude1(), DELTA); + assertEquals(-105.00, result.getBbox().getLongitude1(), DELTA); + assertEquals(39.74, result.getBbox().getLatitude2(), DELTA); + assertEquals(-104.99, result.getBbox().getLongitude2(), DELTA); + + assertEquals(List.of("CDOT", "Denver"), result.getOrganizations()); + } + + @Test + @DisplayName("intersectionNumber maps to intersectionId") + void toDto_intersectionNumber_mapsToIntersectionId() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("99999"); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertEquals(99999, result.getIntersectionId()); + } + + @Test + @DisplayName("rsus field is always null — it is intentionally ignored") + void toDto_rsusField_alwaysNull() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNull(result.getRsus()); + } + + @Test + @DisplayName("null refPt maps to null refPt in DTO") + void toDto_nullRefPt_mapsToNull() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setRefPt(null); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNull(result.getRefPt()); + } + + @Test + @DisplayName("null bbox maps to null bbox in DTO") + void toDto_nullBbox_mapsToNull() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setBbox(null); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNull(result.getBbox()); + } + + @Test + @DisplayName("null intersectionName maps to null in DTO") + void toDto_nullIntersectionName_mapsToNull() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setIntersectionName(null); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNull(result.getIntersectionName()); + } + + @Test + @DisplayName("null originIp maps to null in DTO") + void toDto_nullOriginIp_mapsToNull() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setOriginIp(null); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNull(result.getOriginIp()); + } + + @Test + @DisplayName("InetAddress originIp is serialized to dotted-decimal string") + void toDto_originIp_serializedToString() throws UnknownHostException { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setOriginIp(InetAddress.getByName("10.0.0.1")); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertEquals("10.0.0.1", result.getOriginIp()); + } + + @Test + @DisplayName("null intersectionOrganizations maps to empty organizations list") + void toDto_nullOrganizations_mapsToEmptyList() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setIntersectionOrganizations(null); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertNotNull(result.getOrganizations()); + assertTrue(result.getOrganizations().isEmpty()); + } + + @Test + @DisplayName("organizations with null org entries are filtered out") + void toDto_organizationsWithNullOrg_filtered() { + Intersection entity = new Intersection(); + entity.setIntersectionNumber("12109"); + entity.setIntersectionOrganizations(Arrays.asList(orgEntryNullOrg(), orgEntry("CDOT"))); + + IntersectionDto result = mapper.toDto(entity); + + assertNotNull(result); + assertEquals(List.of("CDOT"), result.getOrganizations()); + } + } + + // ------------------------------------------------------------------------- + // mapOrgNames (default method, tested via the interface) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("mapOrgNames — IntersectionOrganization list to String list") + class MapOrgNamesTests { + + @Test + @DisplayName("null input returns empty list") + void mapOrgNames_null_returnsEmptyList() { + List result = mapper.mapOrgNames(null); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("empty list returns empty list") + void mapOrgNames_emptyList_returnsEmptyList() { + List result = mapper.mapOrgNames(Collections.emptyList()); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("single valid entry returns list with that name") + void mapOrgNames_singleValidEntry_returnsName() { + List result = mapper.mapOrgNames(List.of(orgEntry("CDOT"))); + + assertEquals(List.of("CDOT"), result); + } + + @Test + @DisplayName("multiple valid entries returns all names in order") + void mapOrgNames_multipleValidEntries_returnsAllNames() { + List orgs = List.of( + orgEntry("CDOT"), orgEntry("Denver"), orgEntry("USDOT")); + + List result = mapper.mapOrgNames(orgs); + + assertEquals(List.of("CDOT", "Denver", "USDOT"), result); + } + + @Test + @DisplayName("entry with null Organization is filtered out") + void mapOrgNames_nullOrganization_filtered() { + List orgs = Arrays.asList(orgEntryNullOrg(), orgEntry("CDOT")); + + List result = mapper.mapOrgNames(orgs); + + assertEquals(List.of("CDOT"), result); + } + + @Test + @DisplayName("entry with null org name is filtered out") + void mapOrgNames_nullOrgName_filtered() { + List orgs = Arrays.asList(orgEntryNullName(), orgEntry("Denver")); + + List result = mapper.mapOrgNames(orgs); + + assertEquals(List.of("Denver"), result); + } + + @Test + @DisplayName("all invalid entries returns empty list") + void mapOrgNames_allInvalidEntries_returnsEmptyList() { + List orgs = Arrays.asList(orgEntryNullOrg(), orgEntryNullName()); + + List result = mapper.mapOrgNames(orgs); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("mix of valid and invalid entries returns only valid names") + void mapOrgNames_mixedEntries_returnsOnlyValid() { + List orgs = Arrays.asList( + orgEntry("CDOT"), + orgEntryNullOrg(), + orgEntry("Denver"), + orgEntryNullName(), + orgEntry("USDOT")); + + List result = mapper.mapOrgNames(orgs); + + assertEquals(List.of("CDOT", "Denver", "USDOT"), result); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/ScmsHealthMapperTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/ScmsHealthMapperTest.java new file mode 100644 index 000000000..de3ed2c43 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/ScmsHealthMapperTest.java @@ -0,0 +1,138 @@ +package us.dot.its.jpo.ode.api.mappers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import us.dot.its.jpo.ode.api.models.postgres.projections.ScmsHealthRsuProjection; +import us.dot.its.jpo.ode.api.models.postgres.projections.ScmsHealthRsuProjectionImpl; +import us.dot.its.jpo.ode.api.models.scms.ScmsHealthDto; +import us.dot.its.jpo.ode.api.models.scms.ScmsHealthResponse; + +class ScmsHealthMapperTest { + + private final ScmsHealthMapper mapper = new ScmsHealthMapperImpl(); + + @Test + @DisplayName("Maps projections to ScmsHealthResponse successfully") + void testToResponse_Success() throws UnknownHostException { + String ip = "10.0.0.1"; + InetAddress inetAddress = InetAddress.getByName(ip); + Instant expiration = Instant.parse("2024-03-27T15:00:00Z"); + + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl(inetAddress, true, expiration); + List projections = new ArrayList<>(); + projections.add(projection); + + ScmsHealthResponse response = mapper.toResponse(projections); + + assertNotNull(response); + Map result = response.getScmsHealthByIp(); + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey(ip)); + ScmsHealthDto dto = result.get(ip); + assertNotNull(dto); + assertTrue(dto.getHealth()); + assertEquals(expiration, dto.getExpiration()); + } + + @Test + @DisplayName("Maps projections with inactive health") + void testToResponse_InactiveHealth_ReturnsDtoWithFalse() throws UnknownHostException { + String ip = "10.0.0.1"; + InetAddress inetAddress = InetAddress.getByName(ip); + + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl(inetAddress, false, null); + List projections = new ArrayList<>(); + projections.add(projection); + + ScmsHealthResponse response = mapper.toResponse(projections); + + assertNotNull(response); + Map result = response.getScmsHealthByIp(); + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey(ip)); + assertNotNull(result.get(ip)); + assertFalse(result.get(ip).getHealth()); + } + + @Test + @DisplayName("Maps projections with no health") + void testToResponse_NoHealth_ReturnsNullValue() throws UnknownHostException { + String ip = "10.0.0.1"; + InetAddress inetAddress = InetAddress.getByName(ip); + + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl(inetAddress, null, null); + List projections = new ArrayList<>(); + projections.add(projection); + + ScmsHealthResponse response = mapper.toResponse(projections); + + assertNotNull(response); + Map result = response.getScmsHealthByIp(); + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey(ip)); + assertNull(result.get(ip)); + } + + @Test + @DisplayName("Null input returns null") + void testToDto_NullInput() { + assertNull(mapper.toDto(null)); + } + + @Test + @DisplayName("Maps projections with null health") + void testToDto_NullHealth() throws UnknownHostException { + String ip = "10.0.0.1"; + InetAddress inetAddress = InetAddress.getByName(ip); + + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl(inetAddress, null, null); + + ScmsHealthDto dto = mapper.toDto(projection); + + assertNotNull(dto); + assertNull(dto.getHealth()); + assertNull(dto.getExpiration()); + } + + @Test + @DisplayName("Empty input returns response with empty map") + void testToResponse_EmptyInput() { + ScmsHealthResponse response = mapper.toResponse(new ArrayList<>()); + assertNotNull(response); + assertNotNull(response.getScmsHealthByIp()); + assertTrue(response.getScmsHealthByIp().isEmpty()); + } + + @Test + @DisplayName("Maps projection to DTO successfully") + void testToDto_Success() throws UnknownHostException { + String ip = "10.0.0.1"; + InetAddress inetAddress = InetAddress.getByName(ip); + Instant expiration = Instant.parse("2024-03-27T15:00:00Z"); + + ScmsHealthRsuProjection projection = new ScmsHealthRsuProjectionImpl(inetAddress, true, expiration); + + ScmsHealthDto dto = mapper.toDto(projection); + + assertNotNull(dto); + assertTrue(dto.getHealth()); + assertEquals(expiration, dto.getExpiration()); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/SimplePositionMapperTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/SimplePositionMapperTest.java new file mode 100644 index 000000000..026e28726 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/mappers/SimplePositionMapperTest.java @@ -0,0 +1,127 @@ +package us.dot.its.jpo.ode.api.mappers; + +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; + +import us.dot.its.jpo.ode.api.models.SimplePosition; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; + +class SimplePositionMapperTest { + + private static final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + private static final double DELTA = 0.0000001; // Precision for double comparisons + + @Nested + @DisplayName("Tests for mapPointToSimplePosition mapper method") + class MapPointToSimplePositionTests { + @Test + void testMapPointToSimplePosition_Success() { + double latitude = 39.7392; + double longitude = -104.9903; + Point point = geometryFactory.createPoint(new Coordinate(longitude, latitude)); + + SimplePosition result = SimplePositionMapper.mapPointToSimplePosition(point); + + assertNotNull(result); + assertEquals(latitude, result.latitude(), DELTA); + assertEquals(longitude, result.longitude(), DELTA); + } + + @Test + void testMapPointToSimplePosition_Null() { + SimplePosition result = SimplePositionMapper.mapPointToSimplePosition(null); + + assertNull(result); + } + } + + @Nested + @DisplayName("Tests for mapSimplePositionToPoint mapper method") + class MapSimplePositionToPointTests { + @Test + void testMapSimplePositionToPoint_Success() { + double latitude = 39.7392; + double longitude = -104.9903; + SimplePosition position = new SimplePosition(latitude, longitude); + + Point result = SimplePositionMapper.mapSimplePositionToPoint(position); + + assertNotNull(result); + assertEquals(latitude, result.getY(), DELTA); + assertEquals(longitude, result.getX(), DELTA); + assertEquals(4326, result.getSRID()); + } + + @Test + void testMapSimplePositionToPoint_Null() { + Point result = SimplePositionMapper.mapSimplePositionToPoint(null); + + assertNull(result); + } + } + + @Nested + @DisplayName("Round trip conversion tests for Point <-> SimplePosition") + class RoundTripConversionTests { + @Test + void testRoundTripConversion_PointToSimplePositionToPoint() { + double latitude = 39.7392; + double longitude = -104.9903; + Point originalPoint = geometryFactory.createPoint(new Coordinate(longitude, latitude)); + + SimplePosition simplePosition = SimplePositionMapper.mapPointToSimplePosition(originalPoint); + Point resultPoint = SimplePositionMapper.mapSimplePositionToPoint(simplePosition); + + assertNotNull(resultPoint); + assertEquals(originalPoint.getY(), resultPoint.getY(), DELTA); + assertEquals(originalPoint.getX(), resultPoint.getX(), DELTA); + assertEquals(originalPoint.getSRID(), resultPoint.getSRID()); + } + + @Test + void testRoundTripConversion_SimplePositionToPointToSimplePosition() { + double latitude = 39.7392; + double longitude = -104.9903; + SimplePosition originalPosition = new SimplePosition(latitude, longitude); + + Point point = SimplePositionMapper.mapSimplePositionToPoint(originalPosition); + SimplePosition resultPosition = SimplePositionMapper.mapPointToSimplePosition(point); + + assertNotNull(resultPosition); + assertEquals(originalPosition.latitude(), resultPosition.latitude(), DELTA); + assertEquals(originalPosition.longitude(), resultPosition.longitude(), DELTA); + } + + @Test + void testRoundTripConversion_MultipleLocations() { + double[][] locations = { + { 39.7392, -104.9903 }, // Denver, CO + { 35.6762, 139.6503 }, // Tokyo, Japan + { -33.8688, 151.2093 }, // Sydney, Australia + { -34.6037, -58.3816 }, // Buenos Aires, Argentina + { 51.5074, -0.1278 } // London, UK + }; + + for (double[] coords : locations) { + double latitude = coords[0]; + double longitude = coords[1]; + + SimplePosition originalPosition = new SimplePosition(latitude, longitude); + Point point = SimplePositionMapper.mapSimplePositionToPoint(originalPosition); + SimplePosition resultPosition = SimplePositionMapper.mapPointToSimplePosition(point); + + assertEquals(latitude, resultPosition.latitude(), DELTA, + "Latitude mismatch for coordinates: " + latitude + ", " + longitude); + assertEquals(longitude, resultPosition.longitude(), DELTA, + "Longitude mismatch for coordinates: " + latitude + ", " + longitude); + } + } + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/UserRoleTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/UserRoleTest.java new file mode 100644 index 000000000..88106d470 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/UserRoleTest.java @@ -0,0 +1,112 @@ +package us.dot.its.jpo.ode.api.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class UserRoleTest { + + @Nested + class FromStringTests { + @Test + void testFromString_User() { + UserRole role = UserRole.fromString("user"); + assertEquals(UserRole.USER, role); + + role = UserRole.fromString("User"); + assertEquals(UserRole.USER, role); + + role = UserRole.fromString("USER"); + assertEquals(UserRole.USER, role); + } + + @Test + void testFromString_Operator() { + UserRole role = UserRole.fromString("operator"); + assertEquals(UserRole.OPERATOR, role); + + role = UserRole.fromString("Operator"); + assertEquals(UserRole.OPERATOR, role); + + role = UserRole.fromString("OPERATOR"); + assertEquals(UserRole.OPERATOR, role); + } + + @Test + void testFromString_Admin() { + UserRole role = UserRole.fromString("admin"); + assertEquals(UserRole.ADMIN, role); + + role = UserRole.fromString("Admin"); + assertEquals(UserRole.ADMIN, role); + + role = UserRole.fromString("ADMIN"); + assertEquals(UserRole.ADMIN, role); + } + + @Test + void testFromString_Invalid() { + assertThrows(IllegalArgumentException.class, () -> { + UserRole.fromString("a"); + }); + + assertThrows(IllegalArgumentException.class, () -> { + UserRole.fromString(""); + }); + + assertThrows(IllegalArgumentException.class, () -> { + UserRole.fromString(null); + }); + + assertThrows(IllegalArgumentException.class, () -> { + UserRole.fromString("useradmin"); + }); + } + } + + @Nested + @DisplayName("Tests for checkRoleAbove method") + class CheckRoleAboveTests { + @Test + void testCheckRoleAbove_AdminAboveOperator() { + assertTrue(UserRole.ADMIN.hasMinimumRole(UserRole.OPERATOR)); + } + + @Test + void testCheckRoleAbove_AdminAboveUser() { + assertTrue(UserRole.ADMIN.hasMinimumRole(UserRole.USER)); + } + + @Test + void testCheckRoleAbove_OperatorAboveUser() { + assertTrue(UserRole.OPERATOR.hasMinimumRole(UserRole.USER)); + } + + @Test + void testCheckRoleAbove_SameRole() { + assertTrue(UserRole.ADMIN.hasMinimumRole(UserRole.ADMIN)); + assertTrue(UserRole.OPERATOR.hasMinimumRole(UserRole.OPERATOR)); + assertTrue(UserRole.USER.hasMinimumRole(UserRole.USER)); + } + + @Test + void testCheckRoleAbove_UserNotAboveOperator() { + assertFalse(UserRole.USER.hasMinimumRole(UserRole.OPERATOR)); + } + + @Test + void testCheckRoleAbove_OperatorNotAboveAdmin() { + assertFalse(UserRole.OPERATOR.hasMinimumRole(UserRole.ADMIN)); + } + + @Test + void testCheckRoleAbove_NullRole() { + assertFalse(UserRole.ADMIN.hasMinimumRole(null)); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/keycloak/CvManagerAuthTokenTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/keycloak/CvManagerAuthTokenTest.java new file mode 100644 index 000000000..e371955fd --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/keycloak/CvManagerAuthTokenTest.java @@ -0,0 +1,611 @@ +package us.dot.its.jpo.ode.api.models.keycloak; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import us.dot.its.jpo.ode.api.models.UserRole; + +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("CvManagerAuthToken Tests") +class CvManagerAuthTokenTest { + + private Jwt createMockJwt(Map cvmanagerData) { + return Jwt.withTokenValue("mock-token") + .header("alg", "RS256") + .header("typ", "JWT") + .claim("sub", "test-user-id") + .claim("preferred_username", "testuser") + .claim("cvmanager_data", cvmanagerData) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + } + + private Jwt createMockJwtWithEmail(Map cvmanagerData, String email, String preferredUsername) { + Jwt.Builder builder = Jwt.withTokenValue("mock-token") + .header("alg", "RS256") + .header("typ", "JWT") + .claim("sub", "test-user-id") + .claim("cvmanager_data", cvmanagerData) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)); + + if (email != null) { + builder.claim("email", email); + } + if (preferredUsername != null) { + builder.claim("preferred_username", preferredUsername); + } + + return builder.build(); + } + + private Map createCvManagerData(String superUser, List> organizations) { + Map cvmanagerData = new HashMap<>(); + cvmanagerData.put("super_user", superUser); + cvmanagerData.put("organizations", organizations); + cvmanagerData.put("user_created_timestamp", System.currentTimeMillis()); + return cvmanagerData; + } + + private List> createOrganizations(String... orgRolePairs) { + List> organizations = new ArrayList<>(); + for (int i = 0; i < orgRolePairs.length; i += 2) { + Map org = new HashMap<>(); + org.put("org", orgRolePairs[i]); + org.put("role", orgRolePairs[i + 1]); + organizations.add(org); + } + return organizations; + } + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Should create token with super user flag set to true") + void shouldCreateTokenWithSuperUserTrue() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("1", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + // Act + CvManagerAuthToken token = new CvManagerAuthToken(jwt, authorities, "testuser"); + + // Assert + assertTrue(token.isSuperUser()); + } + + @Test + @DisplayName("Should create token with super user flag set to false") + void shouldCreateTokenWithSuperUserFalse() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + // Act + CvManagerAuthToken token = new CvManagerAuthToken(jwt, authorities, "testuser"); + + // Assert + assertFalse(token.isSuperUser()); + } + + @Test + @DisplayName("Should parse multiple organizations correctly") + void shouldParseMultipleOrganizations() { + // Arrange + List> orgs = createOrganizations( + "CDOT", "admin", + "WYDOT", "operator", + "VDOT", "user"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + // Act + CvManagerAuthToken token = new CvManagerAuthToken(jwt, authorities, "testuser"); + + // Assert + assertEquals(3, token.getAllOrgs().size()); + assertTrue(token.hasRoleInOrg("CDOT", "admin")); + assertTrue(token.hasRoleInOrg("WYDOT", "operator")); + assertTrue(token.hasRoleInOrg("VDOT", "user")); + } + } + + @Nested + @DisplayName("getEmail Tests") + class GetEmailTests { + + @Test + @DisplayName("Should return email from 'email' claim") + void shouldReturnEmailFromEmailClaim() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, "user@example.com", null); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertEquals("user@example.com", email); + } + + @Test + @DisplayName("Should return email from 'preferred_username' when 'email' is null") + void shouldReturnEmailFromPreferredUsername() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, null, "preferred@example.com"); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertEquals("preferred@example.com", email); + } + + @Test + @DisplayName("Should prefer 'email' claim over 'preferred_username'") + void shouldPreferEmailClaimOverPreferredUsername() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, "email@example.com", "preferred@example.com"); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertEquals("email@example.com", email); + } + + @Test + @DisplayName("Should return null when no email claims are present") + void shouldReturnNullWhenNoEmailClaims() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, null, null); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertNull(email); + } + + @Test + @DisplayName("Should return null when email claim is empty string") + void shouldReturnNullWhenEmailIsEmpty() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, "", "preferred@example.com"); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertEquals("preferred@example.com", email); + } + + @Test + @DisplayName("Should return null when both email and preferred_username are empty") + void shouldReturnNullWhenBothEmailsAreEmpty() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, "", ""); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertNull(email); + } + + @ParameterizedTest(name = "Should handle email format: {0}") + @ValueSource(strings = { + "simple@example.com", + "user.name@example.com", + "user_name@example.com", + "user-name@example.com", + "user123@example.co.uk", + "user+test@sub-domain.example.com", + "123@example.com" + }) + void shouldHandleEmailInVariousFormats(String testEmail) { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwtWithEmail(cvmanagerData, testEmail, null); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + String email = token.getEmail(); + + // Assert + assertEquals(testEmail, email); + } + } + + @Nested + @DisplayName("Email Integration Tests") + class EmailIntegrationTests { + + @Test + @DisplayName("Should extract email correctly with full token structure") + void shouldExtractEmailWithFullTokenStructure() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin", "WYDOT", "user"); + Map cvmanagerData = createCvManagerData("1", orgs); + Jwt jwt = Jwt.withTokenValue("mock-token") + .header("alg", "RS256") + .header("typ", "JWT") + .claim("sub", "123e4567-e89b-12d3-a456-426614174000") + .claim("email", "admin@example.com") + .claim("preferred_username", "adminuser") + .claim("name", "Admin User") + .claim("given_name", "Admin") + .claim("family_name", "User") + .claim("cvmanager_data", cvmanagerData) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "adminuser"); + + // Assert + assertEquals("admin@example.com", token.getEmail()); + assertTrue(token.isSuperUser()); + assertEquals(2, token.getAllOrgs().size()); + } + + @Test + @DisplayName("Should handle token with only preferred_username and no email") + void shouldHandleTokenWithOnlyPreferredUsername() { + // Arrange + List> orgs = createOrganizations("CDOT", "operator"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = Jwt.withTokenValue("mock-token") + .header("alg", "RS256") + .claim("sub", "user-123") + .claim("preferred_username", "operator@example.com") + .claim("cvmanager_data", cvmanagerData) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "operator"); + + // Assert + assertEquals("operator@example.com", token.getEmail()); + assertFalse(token.isSuperUser()); + assertTrue(token.hasRoleInOrg("CDOT", "operator")); + } + } + + @Nested + @DisplayName("isSuperUser Tests") + class IsSuperUserTests { + + @Test + @DisplayName("Should return true when super_user is '1'") + void shouldReturnTrueWhenSuperUserIs1() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("1", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act & Assert + assertTrue(token.isSuperUser()); + } + + @Test + @DisplayName("Should return false when super_user is '0'") + void shouldReturnFalseWhenSuperUserIs0() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act & Assert + assertFalse(token.isSuperUser()); + } + + @Test + @DisplayName("Should return false when super_user is null") + void shouldReturnFalseWhenSuperUserIsNull() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData(null, orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act & Assert + assertFalse(token.isSuperUser()); + } + + @Test + @DisplayName("Should return false when super_user is any other string") + void shouldReturnFalseWhenSuperUserIsOtherString() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("true", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act & Assert + assertFalse(token.isSuperUser()); + } + } + + @Nested + @DisplayName("hasRoleInOrg Tests") + class HasRoleInOrgTests { + + private CvManagerAuthToken token; + + @BeforeEach + void setUp() { + List> orgs = createOrganizations( + "CDOT", "admin", + "WYDOT", "operator", + "VDOT", "user"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + } + + @Test + @DisplayName("Should return true when user has exact role in org") + void shouldReturnTrueWhenUserHasExactRole() { + assertTrue(token.hasRoleInOrg("CDOT", "admin")); + assertTrue(token.hasRoleInOrg("WYDOT", "operator")); + assertTrue(token.hasRoleInOrg("VDOT", "user")); + } + + @Test + @DisplayName("Should return false when user has different role in org") + void shouldReturnFalseWhenUserHasDifferentRole() { + assertFalse(token.hasRoleInOrg("CDOT", "operator")); + assertFalse(token.hasRoleInOrg("WYDOT", "admin")); + assertFalse(token.hasRoleInOrg("VDOT", "operator")); + } + + @Test + @DisplayName("Should return false when org does not exist") + void shouldReturnFalseWhenOrgDoesNotExist() { + assertFalse(token.hasRoleInOrg("NONEXISTENT", "admin")); + } + + @Test + @DisplayName("Should be case-insensitive for role comparison") + void shouldBeCaseInsensitiveForRole() { + assertTrue(token.hasRoleInOrg("CDOT", "admin")); + assertTrue(token.hasRoleInOrg("CDOT", "Admin")); + assertTrue(token.hasRoleInOrg("CDOT", "aDmIn")); + } + } + + @Nested + @DisplayName("getAllOrgs Tests") + class GetAllOrgsTests { + + @Test + @DisplayName("Should return all organization names") + void shouldReturnAllOrganizationNames() { + // Arrange + List> orgs = createOrganizations( + "CDOT", "admin", + "WYDOT", "operator", + "VDOT", "user"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + Set allOrgs = token.getAllOrgs(); + + // Assert + assertEquals(3, allOrgs.size()); + assertTrue(allOrgs.contains("CDOT")); + assertTrue(allOrgs.contains("WYDOT")); + assertTrue(allOrgs.contains("VDOT")); + } + + @Test + @DisplayName("Should return empty set when user has no organizations") + void shouldReturnEmptySetWhenNoOrganizations() { + // Arrange + List> orgs = createOrganizations(); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + Set allOrgs = token.getAllOrgs(); + + // Assert + assertTrue(allOrgs.isEmpty()); + } + } + + @Nested + @DisplayName("getQualifiedOrgList Tests") + class GetQualifiedOrgListTests { + + private CvManagerAuthToken token; + + @BeforeEach + void setUp() { + List> orgs = createOrganizations( + "CDOT", "admin", + "WYDOT", "operator", + "VDOT", "user", + "TXDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + } + + @Test + @DisplayName("Should return all orgs when required role is 'user'") + void shouldReturnAllOrgsForUserRole() { + // Act + List qualifiedOrgs = token.getQualifiedOrgList(UserRole.USER); + + // Assert + assertEquals(4, qualifiedOrgs.size()); + assertTrue(qualifiedOrgs.containsAll(List.of("CDOT", "WYDOT", "VDOT", "TXDOT"))); + } + + @Test + @DisplayName("Should return orgs with operator or higher when required role is 'operator'") + void shouldReturnOperatorAndAboveOrgs() { + // Act + List qualifiedOrgs = token.getQualifiedOrgList(UserRole.OPERATOR); + + // Assert + assertEquals(3, qualifiedOrgs.size()); + assertTrue(qualifiedOrgs.containsAll(List.of("CDOT", "WYDOT", "TXDOT"))); + assertFalse(qualifiedOrgs.contains("VDOT")); + } + + @Test + @DisplayName("Should return only admin orgs when required role is 'admin'") + void shouldReturnOnlyAdminOrgs() { + // Act + List qualifiedOrgs = token.getQualifiedOrgList(UserRole.ADMIN); + + // Assert + assertEquals(2, qualifiedOrgs.size()); + assertTrue(qualifiedOrgs.containsAll(List.of("CDOT", "TXDOT"))); + assertFalse(qualifiedOrgs.contains("WYDOT")); + assertFalse(qualifiedOrgs.contains("VDOT")); + } + + @Test + @DisplayName("Should return empty list when no orgs meet the requirement") + void shouldReturnEmptyListWhenNoOrgsMeetRequirement() { + // Arrange + List> orgs = createOrganizations("CDOT", "user"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken tokenWithOnlyUser = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Act + List qualifiedOrgs = tokenWithOnlyUser.getQualifiedOrgList(UserRole.ADMIN); + + // Assert + assertTrue(qualifiedOrgs.isEmpty()); + } + } + + @Nested + @DisplayName("findRoleInOrg Tests") + class FindRoleInOrgTests { + + private CvManagerAuthToken token; + + @BeforeEach + void setUp() { + List> orgs = createOrganizations( + "CDOT", "admin", + "WYDOT", "operator", + "VDOT", "user"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + } + + @Test + @DisplayName("Should return role when org exists") + void shouldReturnRoleWhenOrgExists() { + // Act & Assert + assertEquals(Optional.of(UserRole.ADMIN), token.findRoleInOrg("CDOT")); + assertEquals(Optional.of(UserRole.OPERATOR), token.findRoleInOrg("WYDOT")); + assertEquals(Optional.of(UserRole.USER), token.findRoleInOrg("VDOT")); + } + + @Test + @DisplayName("Should return empty when org does not exist") + void shouldReturnEmptyWhenOrgDoesNotExist() { + // Act + Optional role = token.findRoleInOrg("NONEXISTENT"); + + // Assert + assertTrue(role.isEmpty()); + } + + @Test + @DisplayName("Should be case-insensitive for org name") + void shouldBeCaseInsensitiveForOrgName() { + // Act & Assert + assertEquals(Optional.of(UserRole.ADMIN), token.findRoleInOrg("cdot")); + assertEquals(Optional.of(UserRole.ADMIN), token.findRoleInOrg("CdOt")); + assertEquals(Optional.of(UserRole.OPERATOR), token.findRoleInOrg("wydot")); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCaseTests { + + @Test + @DisplayName("Should handle empty organizations list") + void shouldHandleEmptyOrganizationsList() { + // Arrange + List> orgs = createOrganizations(); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Assert + assertTrue(token.getAllOrgs().isEmpty()); + assertTrue(token.getQualifiedOrgList(UserRole.USER).isEmpty()); + assertTrue(token.findRoleInOrg("CDOT").isEmpty()); + assertFalse(token.hasRoleInOrg("CDOT", "admin")); + } + + @Test + @DisplayName("Should handle single organization") + void shouldHandleSingleOrganization() { + // Arrange + List> orgs = createOrganizations("CDOT", "admin"); + Map cvmanagerData = createCvManagerData("0", orgs); + Jwt jwt = createMockJwt(cvmanagerData); + CvManagerAuthToken token = new CvManagerAuthToken(jwt, Collections.emptyList(), "testuser"); + + // Assert + assertEquals(1, token.getAllOrgs().size()); + assertTrue(token.hasRoleInOrg("CDOT", "admin")); + assertEquals(List.of("CDOT"), token.getQualifiedOrgList(UserRole.ADMIN)); + } + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/derived/EmailSubscriptionTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/derived/EmailSubscriptionTest.java new file mode 100644 index 000000000..d9de72f0c --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/derived/EmailSubscriptionTest.java @@ -0,0 +1,98 @@ +package us.dot.its.jpo.ode.api.models.postgres.derived; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import us.dot.its.jpo.ode.api.models.emails.UserEmailNotificationDto; + +@ExtendWith(MockitoExtension.class) +public class EmailSubscriptionTest { + + @Test + void testGetSubscribedTrue() { + UserEmailNotificationDto sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, false, + false, + false, true, true, true, true, true); + assertTrue(sub1.getSubscribed()); + + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, true, false, false, + false, true, true, true, true, true); + assertTrue(sub1.getSubscribed()); + + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, true, false, + false, true, true, true, true, true); + assertTrue(sub1.getSubscribed()); + + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, true, + false, true, true, true, true, true); + assertTrue(sub1.getSubscribed()); + + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, false, + true, true, true, true, true, true); + assertTrue(sub1.getSubscribed()); + } + + @Test + void testGetSubscribedFalse() { + UserEmailNotificationDto sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, + false, + false, true, true, true, true, true); + assertFalse(sub1.getSubscribed()); + } + + @Test + void testIsFrequencyEqualTrue() { + // Both have no frequencies selected + UserEmailNotificationDto sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, + false, + false, true, true, true, true, true); + UserEmailNotificationDto sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, + false, + false, true, true, true, true, true); + assertTrue(sub1.isFrequencyEqual(sub2)); + + // Both have some frequencies selected + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, false, false, + false, true, true, true, true, true); + sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, false, false, + false, true, true, true, true, true); + assertTrue(sub1.isFrequencyEqual(sub2)); + + // Both have all frequencies selected + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", true, true, true, true, + true, true, true, true, true, true); + sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", true, true, true, true, + true, true, true, true, true, true); + assertTrue(sub1.isFrequencyEqual(sub2)); + } + + @Test + void testIsFrequencyEqualFalse() { + // One has no frequencies selected + UserEmailNotificationDto sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, false, + false, + false, true, true, true, true, true); + UserEmailNotificationDto sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", false, false, false, + false, + false, true, true, true, true, true); + assertFalse(sub1.isFrequencyEqual(sub2)); + + // Both have different frequencies selected + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, false, true, + false, true, true, true, true, true); + sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", true, false, true, false, + false, true, true, true, true, true); + assertFalse(sub1.isFrequencyEqual(sub2)); + + // Both have different frequencies selected + sub1 = new UserEmailNotificationDto("category1", "desc1", "USER", false, true, true, true, + false, true, true, true, true, true); + sub2 = new UserEmailNotificationDto("category1", "desc1", "USER", true, true, true, true, + true, true, true, true, true, true); + assertFalse(sub1.isFrequencyEqual(sub2)); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/projections/ScmsHealthRsuProjectionImpl.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/projections/ScmsHealthRsuProjectionImpl.java new file mode 100644 index 000000000..6fc9d3021 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/models/postgres/projections/ScmsHealthRsuProjectionImpl.java @@ -0,0 +1,34 @@ +package us.dot.its.jpo.ode.api.models.postgres.projections; + +import lombok.RequiredArgsConstructor; + +import java.net.InetAddress; +import java.time.Instant; + +/** + * Test implementation of ScmsHealthRsuProjection for unit tests. + * In production, Spring Data JPA creates proxy implementations for native query results. + */ +@RequiredArgsConstructor +public class ScmsHealthRsuProjectionImpl implements ScmsHealthRsuProjection { + + private final InetAddress ipv4Address; + private final Boolean health; + private final Instant expiration; + + @Override + public InetAddress getIpv4Address() { + return ipv4Address; + } + + @Override + public Boolean getHealth() { + return health; + } + + @Override + public Instant getExpiration() { + return expiration; + } +} + diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/AdminIntersectionServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/AdminIntersectionServiceTest.java new file mode 100644 index 000000000..c43e622c9 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/AdminIntersectionServiceTest.java @@ -0,0 +1,792 @@ +package us.dot.its.jpo.ode.api.services; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.web.server.ResponseStatusException; +import us.dot.its.jpo.ode.api.fixtures.TestFixtures; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.models.admin.intersection.AllowedSelections; +import us.dot.its.jpo.ode.api.models.admin.intersection.Bbox; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionCreate; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionListResponse; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionPatch; +import us.dot.its.jpo.ode.api.models.admin.intersection.IntersectionSingleResponse; +import us.dot.its.jpo.ode.api.models.admin.intersection.RefPt; +import us.dot.its.jpo.ode.api.models.postgres.tables.Intersection; +import us.dot.its.jpo.ode.api.models.postgres.tables.Manufacturer; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuCredential; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuModel; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpCredential; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpProtocol; +import us.dot.its.jpo.ode.api.repositories.IntersectionOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.IntersectionRepository; +import us.dot.its.jpo.ode.api.repositories.ManufacturerRepository; +import us.dot.its.jpo.ode.api.repositories.OrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RsuCredentialRepository; +import us.dot.its.jpo.ode.api.repositories.RsuIntersectionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuModelRepository; +import us.dot.its.jpo.ode.api.repositories.RsuOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpCredentialRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpProtocolRepository; + +import java.net.UnknownHostException; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@ActiveProfiles("integration-test") +class AdminIntersectionServiceTest { + @Autowired + private AdminIntersectionService adminIntersectionService; + + private final TestFixtures fixtures = new TestFixtures(); + + @Autowired + private IntersectionRepository intersectionRepository; + + @Autowired + private IntersectionOrganizationRepository intersectionOrganizationRepository; + + @Autowired + private RsuIntersectionRepository rsuIntersectionRepository; + + @Autowired + private OrganizationRepository organizationRepository; + + @Autowired + private RsuRepository rsuRepository; + + @Autowired + private RsuOrganizationRepository rsuOrganizationRepository; + + @Autowired + private RsuCredentialRepository rsuCredentialRepository; + + @Autowired + private SnmpCredentialRepository snmpCredentialRepository; + + @Autowired + private SnmpProtocolRepository snmpProtocolRepository; + + @Autowired + private RsuModelRepository rsuModelRepository; + + @Autowired + private ManufacturerRepository manufacturerRepository; + + /** + * Clears all relevant tables in reverse FK-dependency order before each test so that + * tests never see each other's data and hardcoded identifiers ("1123", "1000", etc.) + * never produce duplicate-key violations. + * + * Deletion order (leaf tables first): + * rsu_intersection → intersection_organization → rsu_organization + * → rsus → rsu_credentials → snmp_credentials → snmp_protocols → rsu_models + * → intersections → organizations + * + * Note: manufacturer rows are left as orphans (no unique constraint in test data). + */ + @BeforeEach + void clearDatabaseBeforeTest() { + clearDatabase(); + } + + /** + * Run cleanup after each test as well. Without @Transactional on this class, + * data committed by the last test method would otherwise linger in the shared + * Testcontainers DB and contaminate subsequent test classes (e.g. ScmsHealthServiceTest). + */ + @AfterEach + void clearDatabaseAfterTest() { + clearDatabase(); + SecurityContextHolder.clearContext(); + } + + private void clearDatabase() { + rsuIntersectionRepository.deleteAll(); + intersectionOrganizationRepository.deleteAll(); + rsuOrganizationRepository.deleteAll(); + rsuRepository.deleteAll(); + rsuCredentialRepository.deleteAll(); + snmpCredentialRepository.deleteAll(); + snmpProtocolRepository.deleteAll(); + rsuModelRepository.deleteAll(); + intersectionRepository.deleteAll(); + organizationRepository.deleteAll(); + } + + private void setUpSuperuserContext() { + Jwt jwt = Jwt.withTokenValue("test-token") + .header("alg", "RS256") + .claim("sub", "superuser") + .claim("preferred_username", "superuser") + .claim("cvmanager_data", Map.of("super_user", "1", "organizations", List.of())) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new CvManagerAuthToken(jwt, List.of(), "superuser")); + } + + private void setUpOperatorContext(String orgName) { + Jwt jwt = Jwt.withTokenValue("test-token") + .header("alg", "RS256") + .claim("sub", "operator") + .claim("preferred_username", "operator") + .claim("cvmanager_data", Map.of( + "super_user", "0", + "organizations", List.of(Map.of("org", orgName, "role", "OPERATOR")) + )) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new CvManagerAuthToken(jwt, List.of(), "operator")); + } + + @Nested + class GetAllIntersections { + + @Test + void withOrg_returnsScopedIntersections() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + + + IntersectionListResponse result = adminIntersectionService.getAllIntersections(orgName); + + assertNotNull(result.getIntersectionData()); + assertEquals(1, result.getIntersectionData().size()); + assertEquals(1123, result.getIntersectionData().getFirst().getIntersectionId()); + } + + @Test + void withOrg_excludesIntersectionsFromOtherOrgs() { + Organization orgA = organizationRepository.save(fixtures.createRandomOrg()); + Organization orgB = organizationRepository.save(fixtures.createRandomOrg()); + Intersection i1 = intersectionRepository.save(fixtures.createIntersection("1001")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i1, orgA)); + Intersection i2 = intersectionRepository.save(fixtures.createIntersection("1002")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i2, orgB)); + + + IntersectionListResponse result = adminIntersectionService.getAllIntersections(orgA.getName()); + + assertEquals(1, result.getIntersectionData().size()); + assertEquals(1001, result.getIntersectionData().getFirst().getIntersectionId()); + } + + @Test + void noIntersectionsForOrg_returnsEmptyList() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + intersectionRepository.save(fixtures.createIntersection("1123")); // no org association + + IntersectionListResponse result = adminIntersectionService.getAllIntersections(org.getName()); + + assertNotNull(result.getIntersectionData()); + assertTrue(result.getIntersectionData().isEmpty()); + } + + @Test + void attachesRsuIpsToCorrectIntersection() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, i)); + + + IntersectionListResponse result = adminIntersectionService.getAllIntersections(orgName); + + assertEquals(1, result.getIntersectionData().size()); + assertEquals(List.of("192.168.1.1"), result.getIntersectionData().getFirst().getRsus()); + } + } + + @Nested + class GetIntersectionsNotInOrganization { + + @Test + void excludesIntersectionsInThatOrg() { + Organization orgA = organizationRepository.save(fixtures.createRandomOrg()); + Organization orgB = organizationRepository.save(fixtures.createRandomOrg()); + Intersection inA = intersectionRepository.save(fixtures.createIntersection("2001")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(inA, orgA)); + Intersection inB = intersectionRepository.save(fixtures.createIntersection("2002")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(inB, orgB)); + + IntersectionListResponse result = + adminIntersectionService.getIntersectionsNotInOrganization(orgA.getName()); + + assertEquals(1, result.getIntersectionData().size()); + assertEquals(2002, result.getIntersectionData().getFirst().getIntersectionId()); + } + + @Test + void includesIntersectionsWithNoOrg() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + Intersection orphan = intersectionRepository.save(fixtures.createIntersection("2003")); + + IntersectionListResponse result = + adminIntersectionService.getIntersectionsNotInOrganization(org.getName()); + + assertEquals(1, result.getIntersectionData().size()); + assertEquals(2003, result.getIntersectionData().getFirst().getIntersectionId()); + } + + @Test + void intersectionInMultipleOrgs_excludedFromAllOrgsItBelongsTo() { + Organization orgA = organizationRepository.save(fixtures.createRandomOrg()); + Organization orgB = organizationRepository.save(fixtures.createRandomOrg()); + Intersection both = intersectionRepository.save(fixtures.createIntersection("2004")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(both, orgA)); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(both, orgB)); + + IntersectionListResponse resultA = + adminIntersectionService.getIntersectionsNotInOrganization(orgA.getName()); + IntersectionListResponse resultB = + adminIntersectionService.getIntersectionsNotInOrganization(orgB.getName()); + + assertTrue(resultA.getIntersectionData().isEmpty()); + assertTrue(resultB.getIntersectionData().isEmpty()); + } + + @Test + void allIntersectionsInOrg_returnsEmptyList() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + Intersection i = intersectionRepository.save(fixtures.createIntersection("2005")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + + IntersectionListResponse result = + adminIntersectionService.getIntersectionsNotInOrganization(org.getName()); + + assertNotNull(result.getIntersectionData()); + assertTrue(result.getIntersectionData().isEmpty()); + } + + @Test + void noIntersectionsExist_returnsEmptyList() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + + IntersectionListResponse result = + adminIntersectionService.getIntersectionsNotInOrganization(org.getName()); + + assertNotNull(result.getIntersectionData()); + assertTrue(result.getIntersectionData().isEmpty()); + } + + @Test + void populatesRsuIpsOnReturnedIntersections() throws UnknownHostException { + Organization targetOrg = organizationRepository.save(fixtures.createRandomOrg()); + Organization otherOrg = organizationRepository.save(fixtures.createRandomOrg()); + Intersection available = intersectionRepository.save(fixtures.createIntersection("2006")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(available, otherOrg)); + Rsu rsu = saveRsu("10.0.0.42", otherOrg); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, available)); + + IntersectionListResponse result = + adminIntersectionService.getIntersectionsNotInOrganization(targetOrg.getName()); + + assertEquals(1, result.getIntersectionData().size()); + assertEquals(List.of("10.0.0.42"), result.getIntersectionData().getFirst().getRsus()); + } + } + + @Nested + class GetIntersection { + + @Test + void notFound_throwsEntityNotFoundException() { + organizationRepository.save(fixtures.createRandomOrg()); + + assertThrows(EntityNotFoundException.class, () -> adminIntersectionService.getIntersection(Integer.MAX_VALUE), + "Should throw EntityNotFoundException for non-existent intersection"); + } + + @Test + void foundAsSuperuser_returnsFullDataWithAllOrgs() throws UnknownHostException { + setUpSuperuserContext(); + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, i)); + + + IntersectionSingleResponse result = adminIntersectionService.getIntersection(1123); + + assertEquals(1123, result.getIntersectionDto().getIntersectionId()); + assertNotNull(result.getAllowedSelections()); + assertTrue(result.getAllowedSelections().getOrganizations().contains(orgName)); + assertEquals(List.of("192.168.1.1"), result.getIntersectionDto().getRsus()); + } + + @Test + void found_singleOrg_returnsOrgInDto() { + setUpSuperuserContext(); + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + + + IntersectionSingleResponse result = adminIntersectionService.getIntersection(1123); + + assertEquals(1123, result.getIntersectionDto().getIntersectionId()); + assertEquals(List.of(orgName), result.getIntersectionDto().getOrganizations()); + } + + @Test + void intersectionWithMultipleOrgs_returnsAllAssignedOrgsInDto() { + setUpSuperuserContext(); + Organization orgA = organizationRepository.save(fixtures.createRandomOrg()); + Organization orgB = organizationRepository.save(fixtures.createRandomOrg()); + String orgAName = orgA.getName(); + String orgBName = orgB.getName(); + + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, orgA)); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, orgB)); + + + IntersectionSingleResponse result = adminIntersectionService.getIntersection(1123); + + assertEquals(1123, result.getIntersectionDto().getIntersectionId()); + assertEquals(2, result.getIntersectionDto().getOrganizations().size()); + assertTrue(result.getIntersectionDto().getOrganizations().containsAll(List.of(orgAName, orgBName))); + } + + @Test + void nonSuperuser_allowedSelectionsUsesOperatorOrgs() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + setUpOperatorContext(orgName); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + Rsu rsu = saveRsu("10.0.0.1", org); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + + IntersectionSingleResponse result = adminIntersectionService.getIntersection(1123); + + AllowedSelections allowed = result.getAllowedSelections(); + assertEquals(List.of(orgName), allowed.getOrganizations()); + assertEquals(List.of("10.0.0.1"), allowed.getRsus()); + } + } + + @Nested + class PatchIntersection { + + @Test + void basicUpdate_renumbersIntersection() { + intersectionRepository.save(fixtures.createIntersection("1000")); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1001, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + assertTrue(intersectionRepository.findByIntersectionNumber("1001").isPresent()); + assertFalse(intersectionRepository.findByIntersectionNumber("1000").isPresent()); + } + + @Test + void withOptionalFields_updatesNonNullFields() { + intersectionRepository.save(fixtures.createIntersection("1000")); + + + Bbox bbox = new Bbox(39.9, -105.2, 40.1, -105.0); + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), bbox, "Main St", null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + Intersection updated = intersectionRepository.findByIntersectionNumber("1000").orElseThrow(); + assertEquals("Main St", updated.getIntersectionName()); + assertNotNull(updated.getBbox()); + } + + @Test + void nullOptionalFields_preservesExistingValues() { + Intersection existing = intersectionRepository.save(fixtures.createIntersection("1000")); + existing.setIntersectionName("Existing Name"); + intersectionRepository.save(existing); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + Intersection updated = intersectionRepository.findByIntersectionNumber("1000").orElseThrow(); + assertEquals("Existing Name", updated.getIntersectionName()); + } + + @Test + void orgsToAdd_createsAssociations() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + intersectionRepository.save(fixtures.createIntersection("1000")); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + List.of(orgName), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + List result = intersectionRepository.findAllByOrgNameWithOrgs(orgName); + assertEquals(1, result.size()); + assertEquals("1000", result.getFirst().getIntersectionNumber()); + } + + @Test + void orgsToRemove_deletesAssociations() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1000")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), List.of(orgName), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + assertTrue(intersectionRepository.findAllByOrgNameWithOrgs(orgName).isEmpty()); + } + + @Test + void rsusToAdd_createsAssociations() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + intersectionRepository.save(fixtures.createIntersection("1000")); + saveRsu("192.168.1.1", org); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + List.of("192.168.1.1"), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + assertEquals(1, rsuIntersectionRepository.findRsuIpsByIntersectionNumber(1000).size()); + } + + @Test + void rsusToRemove_deletesAssociations() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + Intersection intersection = intersectionRepository.save(fixtures.createIntersection("1000")); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, intersection)); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), List.of("192.168.1.1")); + + adminIntersectionService.patchIntersection(patch); + + + assertTrue(rsuIntersectionRepository.findRsuIpsByIntersectionNumber(1000).isEmpty()); + } + + @Test + void intersectionNotFound_throws404() { + IntersectionPatch patch = new IntersectionPatch( + 9999, 9999, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> adminIntersectionService.patchIntersection(patch)); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + + @Test + void rsusToAdd_alreadyAssociated_doesNotCreateDuplicate() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + Intersection intersection = intersectionRepository.save(fixtures.createIntersection("1000")); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, intersection)); + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + List.of("192.168.1.1"), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + assertEquals(1, rsuIntersectionRepository.findAll().size()); + } + + @Test + void emptyRelationshipLists_noAssociations_createdOrRemoved() { + intersectionRepository.save(fixtures.createIntersection("1000")); + + + IntersectionPatch patch = new IntersectionPatch( + 1000, 1000, new RefPt(40.0, -105.0), null, null, null, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + adminIntersectionService.patchIntersection(patch); + + + assertTrue(intersectionOrganizationRepository.findAll().isEmpty()); + assertTrue(rsuIntersectionRepository.findAll().isEmpty()); + } + } + + @Nested + class DeleteIntersection { + + @Test + void existingIntersection_deletesRelationshipsAndReturnsMessage() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + Intersection i = intersectionRepository.save(fixtures.createIntersection("1123")); + intersectionOrganizationRepository.save(fixtures.createIntersectionOrganization(i, org)); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuIntersectionRepository.save(fixtures.createRsuIntersection(rsu, i)); + + + adminIntersectionService.deleteIntersection("1123"); + + assertFalse(intersectionRepository.findByIntersectionNumber("1123").isPresent()); + assertTrue(rsuIntersectionRepository.findRsuIpsByIntersectionNumber(1123).isEmpty()); + assertTrue(intersectionOrganizationRepository.findAll().isEmpty()); + } + + @Test + void notFound_throws404() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> adminIntersectionService.deleteIntersection("9999")); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + @Nested + class CreateIntersection { + + @Test + void happyPath_allFieldsPopulated_savesIntersectionAndAssociations() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Rsu rsu = saveRsu("192.168.1.1", org); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + IntersectionCreate create = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of(orgName), List.of("192.168.1.1"), + new Bbox(40.111, -105.444, 40.133, -105.466), + "Main St & 1st Ave", "10.0.0.1"); + + adminIntersectionService.createIntersection(create); + + // Verify intersection was saved + assertTrue(intersectionRepository.findByIntersectionNumber("12109").isPresent()); + Intersection saved = intersectionRepository.findByIntersectionNumber("12109").orElseThrow(); + assertEquals("Main St & 1st Ave", saved.getIntersectionName()); + assertNotNull(saved.getBbox()); + assertNotNull(saved.getOriginIp()); + + // Verify org association + List byOrg = intersectionRepository.findAllByOrgNameWithOrgs(orgName); + assertEquals(1, byOrg.size()); + + // Verify RSU association + assertEquals(1, rsuIntersectionRepository.findRsuIpsByIntersectionNumber(12109).size()); + } + + @Test + void optionalFieldsOmitted_savesWithNulls() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + + IntersectionCreate create = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of(orgName), List.of(), + null, null, null); + + adminIntersectionService.createIntersection(create); + + Intersection saved = intersectionRepository.findByIntersectionNumber("12109").orElseThrow(); + assertNull(saved.getIntersectionName()); + assertNull(saved.getBbox()); + assertNull(saved.getOriginIp()); + } + + @Test + void emptyRsuList_skipsRsuAssociationStep() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + + IntersectionCreate create = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of(orgName), List.of(), + null, null, null); + + adminIntersectionService.createIntersection(create); + + assertTrue(rsuIntersectionRepository.findRsuIpsByIntersectionNumber(12109).isEmpty()); + assertTrue(intersectionRepository.findByIntersectionNumber("12109").isPresent()); + } + + @Test + void duplicateIntersectionNumber_throwsDataIntegrityViolationException() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + + IntersectionCreate first = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of(orgName), List.of(), + null, null, null); + adminIntersectionService.createIntersection(first); + + IntersectionCreate duplicate = new IntersectionCreate( + 12109, new RefPt(40.789, -105.012), + List.of(orgName), List.of(), + null, null, null); + + assertThrows(DataIntegrityViolationException.class, + () -> adminIntersectionService.createIntersection(duplicate)); + } + + @Test + void nonExistentOrganization_throwsEntityNotFoundException() { + IntersectionCreate create = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of("NonExistentOrg"), List.of(), + null, null, null); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> adminIntersectionService.createIntersection(create)); + assertTrue(ex.getMessage().contains("NonExistentOrg")); + } + + @Test + void nonExistentRsu_throwsEntityNotFoundException() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + + IntersectionCreate create = new IntersectionCreate( + 12109, new RefPt(40.123, -105.456), + List.of(orgName), List.of("192.168.99.99"), + null, null, null); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> adminIntersectionService.createIntersection(create)); + assertTrue(ex.getMessage().contains("192.168.99.99")); + } + } + + @Nested + class BuildAllowedSelections { + + @Test + void superUser_returnsAllOrgsAndRsus() throws UnknownHostException { + setUpSuperuserContext(); + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + Rsu rsu = saveRsu("10.0.0.1", org); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + AllowedSelections result = adminIntersectionService.getAllowedSelections(); + + assertTrue(result.getOrganizations().contains(orgName)); + assertTrue(result.getRsus().contains("10.0.0.1")); + } + + @Test + void nonSuperUserWithOperatorOrgs_returnsScopedOrgsAndRsus() throws UnknownHostException { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + setUpOperatorContext(orgName); + Rsu rsu = saveRsu("10.0.0.1", org); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + AllowedSelections result = adminIntersectionService.getAllowedSelections(); + + assertEquals(List.of(orgName), result.getOrganizations()); + assertEquals(List.of("10.0.0.1"), result.getRsus()); + } + + @Test + void nonSuperUserWithOnlyUserRole_returnsEmptyLists() { + Organization org = organizationRepository.save(fixtures.createRandomOrg()); + String orgName = org.getName(); + // Set up a context with only USER role (not OPERATOR) + Jwt jwt = Jwt.withTokenValue("test-token") + .header("alg", "RS256") + .claim("sub", "user") + .claim("preferred_username", "user") + .claim("cvmanager_data", Map.of( + "super_user", "0", + "organizations", List.of(Map.of("org", orgName, "role", "USER")) + )) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new CvManagerAuthToken(jwt, List.of(), "user")); + + AllowedSelections result = adminIntersectionService.getAllowedSelections(); + + assertTrue(result.getOrganizations().isEmpty()); + assertTrue(result.getRsus().isEmpty()); + } + } + + private Rsu saveRsu(String ip, Organization org) throws UnknownHostException { + Manufacturer mfr = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(mfr)); + RsuCredential cred = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + SnmpCredential snmpCred = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + SnmpProtocol proto = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + return rsuRepository.save(fixtures.createRsu(ip, model, cred, snmpCred, proto)); + } + +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java index a2ac7f9c4..49874d35c 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java @@ -1,240 +1,1061 @@ package us.dot.its.jpo.ode.api.services; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.postmarkapp.postmark.client.ApiClient; -import com.postmarkapp.postmark.client.data.model.message.Message; -import com.postmarkapp.postmark.client.data.model.message.MessageResponse; -import com.postmarkapp.postmark.client.exception.PostmarkException; -import com.sendgrid.Method; -import com.sendgrid.Request; -import com.sendgrid.Response; -import com.sendgrid.SendGrid; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.keycloak.representations.idm.UserRepresentation; import org.mockito.*; -import org.springframework.mail.MailException; -import org.springframework.mail.SimpleMailMessage; -import org.springframework.mail.javamail.JavaMailSender; -import us.dot.its.jpo.ode.api.ConflictMonitorApiProperties; -import us.dot.its.jpo.ode.api.models.EmailFrequency; - -import java.io.IOException; -import java.util.ArrayList; +import org.springframework.security.access.AccessDeniedException; + +import us.dot.its.jpo.ode.api.emails.generators.*; +import us.dot.its.jpo.ode.api.emails.providers.EmailProvider; +import us.dot.its.jpo.ode.api.mappers.UserEmailNotificationMapper; +import us.dot.its.jpo.ode.api.models.emails.EmailCategory; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.EmailFrequency; +import us.dot.its.jpo.ode.api.models.emails.EmailRecipient; +import us.dot.its.jpo.ode.api.models.emails.EmailSendResponse; +import us.dot.its.jpo.ode.api.models.emails.UserEmailNotificationDto; +import us.dot.its.jpo.ode.api.models.emails.contents.ApiErrorEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.FirmwareUpgradeFailureEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.IntersectionNotificationSummaryEmailContents; +import us.dot.its.jpo.ode.api.models.postgres.tables.EmailType; +import us.dot.its.jpo.ode.api.models.postgres.tables.Role; +import us.dot.its.jpo.ode.api.models.postgres.tables.User; +import us.dot.its.jpo.ode.api.models.postgres.tables.UserEmailNotification; +import us.dot.its.jpo.ode.api.models.emails.contents.RsuErrorSummaryEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.SupportRequestEmailContents; +import us.dot.its.jpo.ode.api.models.emails.contents.message_counts.MessageCountEmailContents; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.repositories.UserEmailNotificationRepository; + +import java.util.Arrays; import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; + +import us.dot.its.jpo.ode.api.repositories.EmailTypeRepository; +import us.dot.its.jpo.ode.api.repositories.UserRepository; + +import java.net.InetAddress; + +import static org.mockito.ArgumentMatchers.eq; + +import static org.mockito.Mockito.*; + class EmailServiceTest { @Mock - private JavaMailSender mailSender; - + private EmailProvider emailProvider; @Mock - private SendGrid sendGrid; - + private EmailTypeRepository emailTypeRepository; @Mock - private ApiClient postmark; - + private UserEmailNotificationRepository userEmailNotificationRepository; + @Mock + private IntersectionNotificationSummaryEmailGenerator intersectionNotificationSummaryEmailGenerator; + @Mock + private UserRepository userRepository; + @Mock + private UserEmailNotificationMapper userEmailNotificationMapper; + @Mock + private PermissionService permissionService; + @Mock + private SupportRequestEmailGenerator supportRequestEmailGenerator; + @Mock + private MessageCountEmailGenerator messageCountEmailGenerator; + @Mock + private FirmwareUpgradeFailureEmailGenerator firmwareUpgradeFailureEmailGenerator; @Mock - private ConflictMonitorApiProperties props; + private ApiErrorEmailGenerator apiErrorEmailGenerator; + @Mock + private RsuErrorSummaryEmailGenerator rsuErrorSummaryEmailGenerator; @InjectMocks private EmailService emailService; + private static final String TEST_EMAIL = "user@example.com"; + private static final User TEST_USER = new User(); + private static final Role ROLE_USER = new Role(); + private static final Role ROLE_ADMIN = new Role(); + private static final List VALID_EMAIL_TYPES = Arrays.asList( + createEmailType("Support Requests", "Receive support requests from users", "admin", + true, true, false, false, false), + createEmailType("Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, true, true, true, true), + createEmailType("Daily Message Counts", "Receive automated daily message count emails", "user", + true, false, false, false, false), + createEmailType("Access Requests", "Receive organization access requests from users", "admin", + true, false, false, false, false)); + + private static UserEmailNotification createUserEmailNotification(String category, String description, + String roleName, + boolean immediate, boolean hourly, boolean daily, boolean weekly, boolean monthly, + boolean supports_immediate, boolean supports_hourly, boolean supports_daily, boolean supports_weekly, + boolean supports_monthly) { + EmailType emailType = createEmailType(category, description, roleName, supports_immediate, supports_hourly, + supports_daily, supports_weekly, + supports_monthly); + + UserEmailNotification notification = new UserEmailNotification(); + notification.setEmailType(emailType); + notification.setUser(TEST_USER); + notification.setImmediate(immediate); + notification.setHourly(hourly); + notification.setDaily(daily); + notification.setWeekly(weekly); + notification.setMonthly(monthly); + + return notification; + } + + public static EmailType createEmailType(String category, String description, + String roleName, boolean supports_immediate, boolean supports_hourly, boolean supports_daily, + boolean supports_weekly, + boolean supports_monthly) { + EmailType emailType = new EmailType(); + emailType.setEmailType(category); + emailType.setDescription(description); + if (roleName.equals("user")) { + emailType.setRequiredRole(ROLE_USER); + } else if (roleName.equals("admin")) { + emailType.setRequiredRole(ROLE_ADMIN); + } + emailType.setSupportsImmediate(supports_immediate); + emailType.setSupportsHourly(supports_hourly); + emailType.setSupportsDaily(supports_daily); + emailType.setSupportsWeekly(supports_weekly); + emailType.setSupportsMonthly(supports_monthly); + + return emailType; + } + + /** + * Custom assertion method with optional message. + */ + private void assertNotificationEquals(UserEmailNotification expected, UserEmailNotification actual, + String message) { + String prefix = message != null ? message + ": " : ""; + + assertNotNull(actual, prefix + "Actual notification should not be null"); + assertNotNull(expected, prefix + "Expected notification should not be null"); + + // Compare email type + assertEquals(expected.getEmailType().getEmailType(), actual.getEmailType().getEmailType(), + prefix + "Email type mismatch"); + assertEquals(expected.getEmailType().getDescription(), actual.getEmailType().getDescription(), + prefix + "Description mismatch"); + assertEquals(expected.getEmailType().getRequiredRole().getName(), + actual.getEmailType().getRequiredRole().getName(), + prefix + "Required role mismatch"); + + // Compare frequency flags + assertEquals(expected.getImmediate(), actual.getImmediate(), prefix + "Immediate flag mismatch"); + assertEquals(expected.getHourly(), actual.getHourly(), prefix + "Hourly flag mismatch"); + assertEquals(expected.getDaily(), actual.getDaily(), prefix + "Daily flag mismatch"); + assertEquals(expected.getWeekly(), actual.getWeekly(), prefix + "Weekly flag mismatch"); + assertEquals(expected.getMonthly(), actual.getMonthly(), prefix + "Monthly flag mismatch"); + + // Compare supports flags + assertEquals(expected.getEmailType().getSupportsImmediate(), + actual.getEmailType().getSupportsImmediate(), + prefix + "Supports immediate mismatch"); + assertEquals(expected.getEmailType().getSupportsHourly(), + actual.getEmailType().getSupportsHourly(), + prefix + "Supports hourly mismatch"); + assertEquals(expected.getEmailType().getSupportsDaily(), + actual.getEmailType().getSupportsDaily(), + prefix + "Supports daily mismatch"); + assertEquals(expected.getEmailType().getSupportsWeekly(), + actual.getEmailType().getSupportsWeekly(), + prefix + "Supports weekly mismatch"); + assertEquals(expected.getEmailType().getSupportsMonthly(), + actual.getEmailType().getSupportsMonthly(), + prefix + "Supports monthly mismatch"); + } + + /** + * Compare lists of UserEmailNotification objects. + */ + private void assertNotificationListEquals(List expected, + List actual) { + assertEquals(expected.size(), actual.size(), "List sizes should match"); + + for (int i = 0; i < expected.size(); i++) { + assertNotificationEquals(expected.get(i), actual.get(i), + "Notification at index " + i); + } + } + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - when(props.getEmailFromAddress()).thenReturn("test@example.com"); + + TEST_USER.setEmail(TEST_EMAIL); + ROLE_USER.setName("user"); + ROLE_ADMIN.setName("admin"); + + // Mapper is mocked in this test class; provide behavior for frequency updates. + lenient().when(userEmailNotificationMapper.updateFrequency(any(UserEmailNotificationDto.class), + any(UserEmailNotification.class))).thenAnswer(invocation -> { + UserEmailNotificationDto dto = invocation.getArgument(0); + UserEmailNotification entity = invocation.getArgument(1); + if (dto == null || entity == null) { + return null; + } + entity.setImmediate(dto.getImmediate()); + entity.setHourly(dto.getHourly()); + entity.setDaily(dto.getDaily()); + entity.setWeekly(dto.getWeekly()); + entity.setMonthly(dto.getMonthly()); + return entity; + }); } @Test - void testSendEmailViaSendGrid() throws IOException { - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testSendEmails() { + List recipients = List.of(new EmailRecipient("test@example.com", null)); + EmailContent content = new EmailContent("subject", "body"); + doReturn(List.of()).when(emailProvider).sendBatchedEmails(recipients, content); - when(sendGrid.api(any(Request.class))).thenReturn(new Response()); + emailService.sendEmails(recipients, content); - emailService.sendEmailViaSendGrid(to, subject, text); + verify(emailProvider, times(1)).sendBatchedEmails(recipients, content); + } - verify(sendGrid, times(1)).api(any(Request.class)); + @Test + void testGetUsersForNotificationType() { + when(userEmailNotificationRepository.findUsersByNotificationType("Support Requests", "IMMEDIATE")) + .thenReturn(List.of("user1@example.com", "user2@example.com")); + + List recipients = emailService.getUsersForNotificationType( + EmailCategory.SUPPORT_REQUEST, EmailFrequency.IMMEDIATE); + + assertEquals(2, recipients.size()); + assertEquals("user1@example.com", recipients.get(0).getEmail()); + assertEquals("user2@example.com", recipients.get(1).getEmail()); } @Test - void testSendEmailViaSendGridThrowsException() throws IOException { - // Arrange - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testGetUsersForNotificationTypeByRsu() throws Throwable { + when(userEmailNotificationRepository.findUsersByNotificationTypeAndRsu("Support Requests", + "IMMEDIATE", InetAddress.getByName("1.1.1.1"))) + .thenReturn(List.of("user1@example.com", "user2@example.com")); + + List recipients = emailService.getUsersForNotificationTypeByRsu( + EmailCategory.SUPPORT_REQUEST, "1.1.1.1", EmailFrequency.IMMEDIATE); - // Mock SendGrid to throw an IOException - doThrow(new IOException("SendGrid API error")).when(sendGrid).api(any(Request.class)); + assertEquals(2, recipients.size()); + assertEquals("user1@example.com", recipients.get(0).getEmail()); + assertEquals("user2@example.com", recipients.get(1).getEmail()); + } + + @Test + void testGetUsersForNotificationTypeByOrganization() { + when(userEmailNotificationRepository.findUsersByNotificationTypeAndOrganization("Support Requests", "IMMEDIATE", + "Test Org")) + .thenReturn(List.of("user1@example.com", "user2@example.com")); + + List recipients = emailService.getUsersForNotificationTypeByOrganization( + EmailCategory.SUPPORT_REQUEST, "Test Org", EmailFrequency.IMMEDIATE); + + assertEquals(2, recipients.size()); + assertEquals("user1@example.com", recipients.get(0).getEmail()); + assertEquals("user2@example.com", recipients.get(1).getEmail()); + } + + @Test + void testSendIntersectionNotificationSummaryEmailSendResponses() { + IntersectionNotificationSummaryEmailContents data = new IntersectionNotificationSummaryEmailContents(); + EmailContent content = new EmailContent("subject", "body"); + List recipients = List.of(new EmailRecipient("test@example.com", null)); + List responses = List.of(new EmailSendResponse(0, "OK")); - // Act - emailService.sendEmailViaSendGrid(to, subject, text); + when(intersectionNotificationSummaryEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationType(anyString(), any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(recipients, content)).thenReturn(responses); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); - verify(sendGrid, times(1)).api(captor.capture()); - Request capturedRequest = captor.getValue(); + List result = emailService.sendIntersectionNotificationSummaryEmailSendResponses(data); - assertEquals("mail/send", capturedRequest.getEndpoint()); - assertEquals(Method.POST, capturedRequest.getMethod()); - assertNotNull(capturedRequest.getBody()); + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(recipients, content); } @Test - void testSendEmailViaPostmark() throws IOException, PostmarkException { - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testSendSupportRequest() { + SupportRequestEmailContents data = new SupportRequestEmailContents(); + EmailContent content = new EmailContent("subject", "body"); + List recipients = List.of(new EmailRecipient("test@example.com", null)); + List responses = List.of(new EmailSendResponse(0, "OK")); - when(postmark.deliverMessage(any(Message.class))).thenReturn(null); + when(supportRequestEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationType(anyString(), any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(recipients, content)).thenReturn(responses); - emailService.sendEmailViaPostmark(to, subject, text); + List result = emailService.sendSupportRequest(data); - ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); - verify(postmark, times(1)).deliverMessage(captor.capture()); - Message sentMessage = captor.getValue(); - assertEquals(to, sentMessage.getTo()); - assertEquals(subject, sentMessage.getSubject()); + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(recipients, content); } @Test - void testSendEmailViaPostmarkThrowsException() throws PostmarkException, IOException { - // Arrange - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testSendMessageCounts() { + MessageCountEmailContents data = new MessageCountEmailContents(); + EmailContent content = new EmailContent("subject", "body"); + List recipients = List.of(new EmailRecipient("test@example.com", null)); + List responses = List.of(new EmailSendResponse(0, "OK")); - // Mock Postmark to throw an exception - doThrow(new PostmarkException("Postmark API error", 500)) - .when(postmark).deliverMessage(any(Message.class)); + when(messageCountEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationTypeAndOrganization(any(), any(), + any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(recipients, content)).thenReturn(responses); - // Act - emailService.sendEmailViaPostmark(to, subject, text); + List result = emailService.sendMessageCounts(data); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); - verify(postmark, times(1)).deliverMessage(captor.capture()); - Message capturedMessage = captor.getValue(); + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(recipients, content); + } + + @Test + void testSendFirmwareUpgradeEmail() { + FirmwareUpgradeFailureEmailContents data = new FirmwareUpgradeFailureEmailContents(); + EmailContent content = new EmailContent("subject", "body"); + List recipients = List.of(new EmailRecipient("test@example.com", null)); + List responses = List.of(new EmailSendResponse(0, "OK")); - assertEquals("test@example.com", capturedMessage.getFrom()); - assertEquals(to, capturedMessage.getTo()); - assertEquals(subject, capturedMessage.getSubject()); - assertTrue(capturedMessage.getHtmlBody().contains("Test Body")); + when(firmwareUpgradeFailureEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationTypeAndRsu(anyString(), anyString(), any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(recipients, content)).thenReturn(responses); + + List result = emailService.sendFirmwareUpgradeFailure(data); + + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(recipients, content); } @Test - void testSendEmailViaSpringMail() { - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testSendApiError() { + ApiErrorEmailContents data = new ApiErrorEmailContents(); + EmailContent content = new EmailContent("subject", "body"); + List recipients = List.of(new EmailRecipient("test@example.com", null)); + List responses = List.of(new EmailSendResponse(0, "OK")); + + when(apiErrorEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationType(anyString(), any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(recipients, content)).thenReturn(responses); - emailService.sendEmailViaSpringMail(to, subject, text); + List result = emailService.sendApiError(data); - ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); - verify(mailSender, times(1)).send(captor.capture()); - SimpleMailMessage sentMessage = captor.getValue(); - assertEquals(to, sentMessage.getTo()[0]); - assertEquals(subject, sentMessage.getSubject()); - assertEquals(text, sentMessage.getText()); + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(recipients, content); } @Test - void testSendEmailViaSpringMailThrowsException() { - // Arrange - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testSendRsuErrorSummary() { + RsuErrorSummaryEmailContents data = new RsuErrorSummaryEmailContents("subject", "message"); + EmailContent content = new EmailContent("subject", "body"); + List responses = List.of(new EmailSendResponse(0, "OK")); - // Mock JavaMailSender to throw an exception - doThrow(new MailException("Spring Mail error") { - }).when(mailSender).send(any(SimpleMailMessage.class)); + CvManagerAuthToken authToken = mock(CvManagerAuthToken.class); + when(permissionService.getCvManagerAuthToken()).thenReturn(authToken); + when(authToken.getEmail()).thenReturn("test@example.com"); - // Act - emailService.sendEmailViaSpringMail(to, subject, text); + when(rsuErrorSummaryEmailGenerator.generateEmailBody(data)).thenReturn(content); + when(userEmailNotificationRepository.findUsersByNotificationType(anyString(), any())) + .thenReturn(List.of("test@example.com")); + when(emailProvider.sendBatchedEmails(anyList(), eq(content))).thenReturn(responses); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); - verify(mailSender, times(1)).send(captor.capture()); - SimpleMailMessage capturedMessage = captor.getValue(); + List result = emailService.sendRsuErrorSummary(data); - assertEquals(to, capturedMessage.getTo()[0]); - assertEquals(subject, capturedMessage.getSubject()); - assertEquals(text, capturedMessage.getText()); + assertEquals(responses, result); + verify(emailProvider).sendBatchedEmails(anyList(), eq(content)); } @Test - void testSendSimpleMessageWithSendGrid() throws IOException { - when(props.getEmailBroker()).thenReturn("sendgrid"); - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testUpdateEmailSubscriptions_NoChange() { + + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true), + createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false)); + + UserEmailNotificationDto SUPPORT_REQUEST_DTO = new UserEmailNotificationDto( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false); + + UserEmailNotificationDto INTERSECTION_NOTIFICATION_SUMMARY_DTO = new UserEmailNotificationDto( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true); + + UserEmailNotificationDto DAILY_MESSAGE_COUNTS_DTO = new UserEmailNotificationDto( + "Daily Message Counts", "Receive automated daily message count emails", + "user", + false, false, false, false, false, + true, false, false, false, false); + + UserEmailNotificationDto ACCESS_REQUESTS_DTO = new UserEmailNotificationDto( + "Access Requests", "Receive organization access requests from users", + "admin", + false, false, false, false, false, + true, false, false, false, false); - when(sendGrid.api(any(Request.class))).thenReturn(new Response()); + List emailSubscriptions = SUBSCRIPTION_LIST; - emailService.sendSimpleMessage(to, subject, text); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(emailSubscriptions); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); - verify(sendGrid, times(1)).api(any(Request.class)); + emailService.updateEmailSubscriptions(TEST_EMAIL, true, true, List.of(SUPPORT_REQUEST_DTO, + INTERSECTION_NOTIFICATION_SUMMARY_DTO, DAILY_MESSAGE_COUNTS_DTO, ACCESS_REQUESTS_DTO)); + + verify(userEmailNotificationRepository, never()).deleteAll(); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + verify(userEmailNotificationRepository).findNotificationsByUser(TEST_EMAIL); } @Test - void testSendSimpleMessageWithPostmark() throws IOException, PostmarkException { - when(props.getEmailBroker()).thenReturn("postmark"); - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testUpdateEmailSubscriptions_AddSubscriptions() { + + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true)); + + UserEmailNotificationDto dailyMessageCountsDto = new UserEmailNotificationDto( + "Daily Message Counts", "Receive automated daily message count emails", "user", + true, false, false, false, false, + true, false, false, false, false); + UserEmailNotificationDto accessRequestDto = new UserEmailNotificationDto( + "Access Requests", "Receive organization access requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false); + + UserEmailNotification dailyMessageCounts = createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + true, false, false, false, false, + true, false, false, false, false); + + UserEmailNotification accessRequests = createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false); + + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(SUBSCRIPTION_LIST); + when(userEmailNotificationMapper.toEntity(dailyMessageCountsDto, TEST_USER, + VALID_EMAIL_TYPES.get(2))).thenReturn(dailyMessageCounts); + when(userEmailNotificationMapper.toEntity(accessRequestDto, TEST_USER, + VALID_EMAIL_TYPES.get(3))).thenReturn(accessRequests); + when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(TEST_USER); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); - when(postmark.deliverMessage(any(Message.class))).thenReturn(new MessageResponse()); + emailService.updateEmailSubscriptions(TEST_EMAIL, true, true, + List.of(dailyMessageCountsDto, accessRequestDto)); - emailService.sendSimpleMessage(to, subject, text); + verify(userEmailNotificationRepository).findNotificationsByUser(TEST_EMAIL); + verify(userEmailNotificationRepository, never()).deleteAll(anyList()); - verify(postmark, times(1)).deliverMessage(any(Message.class)); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userEmailNotificationRepository, times(1)).saveAll(captor.capture()); + + List savedNotifications = captor.getValue(); + assertNotificationListEquals( + List.of(dailyMessageCounts, accessRequests), + savedNotifications); } @Test - void testSendSimpleMessageWithSpringMail() { - when(props.getEmailBroker()).thenReturn("other"); - String to = "recipient@example.com"; - String subject = "Test Subject"; - String text = "Test Body"; + void testUpdateEmailSubscriptions_RemoveSubscriptions() { + + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true), + createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false)); + + UserEmailNotificationDto supportRequestsDto = new UserEmailNotificationDto( + "Support Requests", "Receive support requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false); + UserEmailNotificationDto intersectionNotificationSummaryDto = new UserEmailNotificationDto( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + false, false, false, false, false, + true, true, true, true, true); + + UserEmailNotification supportRequests = createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false); + + UserEmailNotification intersectionNotificationSummaries = createUserEmailNotification( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true); - emailService.sendSimpleMessage(to, subject, text); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(SUBSCRIPTION_LIST); + when(userEmailNotificationMapper.toEntity(supportRequestsDto, TEST_USER, + VALID_EMAIL_TYPES.get(0))).thenReturn(supportRequests); + when(userEmailNotificationMapper.toEntity(intersectionNotificationSummaryDto, TEST_USER, + VALID_EMAIL_TYPES.get(1))) + .thenReturn(intersectionNotificationSummaries); + when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(TEST_USER); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); - verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); + emailService.updateEmailSubscriptions(TEST_EMAIL, true, true, + List.of(supportRequestsDto, intersectionNotificationSummaryDto)); + + verify(userEmailNotificationRepository).findNotificationsByUser(TEST_EMAIL); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userEmailNotificationRepository, times(1)).deleteAll(captor.capture()); + + List deletedNotifications = captor.getValue(); + assertNotificationListEquals( + List.of(supportRequests, intersectionNotificationSummaries), + deletedNotifications); } @Test - void testEmailList() { - List users = new ArrayList<>(); - UserRepresentation user1 = new UserRepresentation(); - user1.setEmail("user1@example.com"); - users.add(user1); + void testUpdateEmailSubscriptions_UpdateSubscriptions() { + + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, true, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true), + createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false)); + + UserEmailNotificationDto supportRequestsDto = new UserEmailNotificationDto( + "Support Requests", "Receive support requests from users", "admin", + true, true, false, false, false, + true, true, false, false, false); + UserEmailNotificationDto intersectionNotificationSummaryDto = new UserEmailNotificationDto( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, true, false, false, + true, true, true, true, true); - UserRepresentation user2 = new UserRepresentation(); - user2.setEmail("user2@example.com"); - users.add(user2); + UserEmailNotification supportRequests = createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, true, false, false, false, + true, true, false, false, false); - String subject = "Test Subject"; - String text = "Test Body"; + UserEmailNotification intersectionNotificationSummaries = createUserEmailNotification( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, true, false, false, + true, true, true, true, true); - when(props.getEmailBroker()).thenReturn("other"); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(SUBSCRIPTION_LIST); + when(userEmailNotificationMapper.toEntity(supportRequestsDto, TEST_USER, + SUBSCRIPTION_LIST.get(0).getEmailType())).thenReturn(supportRequests); + when(userEmailNotificationMapper.toEntity(intersectionNotificationSummaryDto, TEST_USER, + SUBSCRIPTION_LIST.get(1).getEmailType())) + .thenReturn(intersectionNotificationSummaries); + when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(TEST_USER); + when(emailTypeRepository.findByEmailType("Support Requests")) + .thenReturn(SUBSCRIPTION_LIST.get(0).getEmailType()); + when(emailTypeRepository.findByEmailType("Intersection Notification Summary")) + .thenReturn(SUBSCRIPTION_LIST.get(1).getEmailType()); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); + + emailService.updateEmailSubscriptions(TEST_EMAIL, true, true, + List.of(supportRequestsDto, intersectionNotificationSummaryDto)); + + verify(userEmailNotificationRepository).findNotificationsByUser(TEST_EMAIL); + verify(userEmailNotificationRepository, never()).deleteAll(anyList()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userEmailNotificationRepository, times(1)).saveAll(captor.capture()); + + List updatedNotifications = captor.getValue(); + assertNotificationListEquals( + List.of(supportRequests, intersectionNotificationSummaries), + updatedNotifications); + } + + @Test + void testUpdateEmailSubscriptions_AllUpdateTypes() { + + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, false, false, false, false, + true, true, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true)); + + UserEmailNotificationDto supportRequestsDto = new UserEmailNotificationDto( + "Support Requests", "Receive support requests from users", "admin", + true, true, false, false, false, + true, true, false, false, false); + UserEmailNotificationDto intersectionNotificationSummaryDto = new UserEmailNotificationDto( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + false, false, false, false, false, + true, true, true, true, true); + UserEmailNotificationDto dailyMessageCountsDto = new UserEmailNotificationDto( + "Daily Message Counts", "Receive automated daily message count emails", "user", + true, false, false, false, false, + true, false, false, false, false); + UserEmailNotificationDto accessRequestDto = new UserEmailNotificationDto( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false); + + UserEmailNotification supportRequests = createUserEmailNotification( + "Support Requests", "Receive support requests from users", "admin", + true, true, false, false, false, + true, true, false, false, false); + UserEmailNotification intersectionNotificationSummaries = createUserEmailNotification( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true); + UserEmailNotification dailyMessageCounts = createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false); + UserEmailNotification accessRequests = createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false); + + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(SUBSCRIPTION_LIST); + when(userEmailNotificationMapper.toEntity(supportRequestsDto, TEST_USER, + VALID_EMAIL_TYPES.get(0))).thenReturn(supportRequests); + when(userEmailNotificationMapper.toEntity(intersectionNotificationSummaryDto, TEST_USER, + VALID_EMAIL_TYPES.get(1))).thenReturn(intersectionNotificationSummaries); + when(userEmailNotificationMapper.toEntity(dailyMessageCountsDto, TEST_USER, + VALID_EMAIL_TYPES.get(2))).thenReturn(dailyMessageCounts); + when(userEmailNotificationMapper.toEntity(accessRequestDto, TEST_USER, + VALID_EMAIL_TYPES.get(3))).thenReturn(accessRequests); + when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(TEST_USER); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); + + emailService.updateEmailSubscriptions(TEST_EMAIL, true, true, + List.of(supportRequestsDto, intersectionNotificationSummaryDto, dailyMessageCountsDto, + accessRequestDto)); + + verify(userEmailNotificationRepository).findNotificationsByUser(TEST_EMAIL); + + // Deleted Intersection Notification Summary notification + ArgumentCaptor> deleteCaptor = ArgumentCaptor.forClass(List.class); + verify(userEmailNotificationRepository, times(1)).deleteAll(deleteCaptor.capture()); + + List deletedNotifications = deleteCaptor.getValue(); + assertNotificationListEquals(List.of(intersectionNotificationSummaries), deletedNotifications); + + // Added Daily Message Counts notification + ArgumentCaptor> saveCaptor = ArgumentCaptor.forClass(List.class); + verify(userEmailNotificationRepository, times(1)).saveAll(saveCaptor.capture()); + + // Get all captured values + List> allSaveInvocations = saveCaptor.getAllValues(); + assertEquals(1, allSaveInvocations.size(), "Should have 1 saveAll invocation"); + + // saveAll call should be for updating Support Requests and adding Daily Message + // Counts + List updatedNotifications = allSaveInvocations.get(0); + assertNotificationListEquals(List.of(supportRequests, dailyMessageCounts), updatedNotifications); + } + + @Test + void testUpdateEmailSubscriptions_NotAuthorized() { + List SUBSCRIPTION_LIST = Arrays.asList( + createUserEmailNotification( + "Support Requests", "Receive support requests from users", "operator", + true, false, false, false, false, + true, true, false, false, false), + createUserEmailNotification( + "Intersection Notification Summary", + "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true), + createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false), + createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false)); + + UserEmailNotificationDto supportRequestsDto = new UserEmailNotificationDto( + "Support Requests", "Receive support requests from users", "operator", + true, true, false, false, false, + true, true, false, false, false); + UserEmailNotificationDto intersectionNotificationSummaryDto = new UserEmailNotificationDto( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + false, false, false, false, false, + true, true, true, true, true); + UserEmailNotificationDto dailyMessageCountsDto = new UserEmailNotificationDto( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false); + UserEmailNotificationDto accessRequestDto = new UserEmailNotificationDto( + "Access Requests", "Receive organization access requests from users", "admin", + true, false, false, false, false, + true, false, false, false, false); + + UserEmailNotification supportRequests = createUserEmailNotification( + "Support Requests", "Receive support requests from users", "operator", + true, true, false, false, false, + true, true, false, false, false); + UserEmailNotification intersectionNotificationSummaries = createUserEmailNotification( + "Intersection Notification Summary", "Receive automated intersection notification summary emails", + "user", + true, false, false, false, false, + true, true, true, true, true); + UserEmailNotification dailyMessageCounts = createUserEmailNotification( + "Daily Message Counts", "Receive automated daily message count emails", "user", + false, false, false, false, false, + true, false, false, false, false); + UserEmailNotification accessRequests = createUserEmailNotification( + "Access Requests", "Receive organization access requests from users", "admin", + false, false, false, false, false, + true, false, false, false, false); + + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(SUBSCRIPTION_LIST); + when(userEmailNotificationMapper.toEntity(supportRequestsDto, TEST_USER, + SUBSCRIPTION_LIST.get(0).getEmailType())).thenReturn(supportRequests); + when(userEmailNotificationMapper.toEntity(intersectionNotificationSummaryDto, TEST_USER, + SUBSCRIPTION_LIST.get(1).getEmailType())) + .thenReturn(intersectionNotificationSummaries); + when(userEmailNotificationMapper.toEntity(dailyMessageCountsDto, TEST_USER, + SUBSCRIPTION_LIST.get(2).getEmailType())).thenReturn(dailyMessageCounts); + when(userEmailNotificationMapper.toEntity(accessRequestDto, TEST_USER, SUBSCRIPTION_LIST.get(3).getEmailType())) + .thenReturn(accessRequests); + when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(TEST_USER); + when(emailTypeRepository.findByEmailType("Support Requests")) + .thenReturn(SUBSCRIPTION_LIST.get(0).getEmailType()); + when(emailTypeRepository.findByEmailType("Intersection Notification Summary")) + .thenReturn(SUBSCRIPTION_LIST.get(1).getEmailType()); + when(emailTypeRepository.findByEmailType("Daily Message Counts")) + .thenReturn(SUBSCRIPTION_LIST.get(2).getEmailType()); + when(emailTypeRepository.findByEmailType("Access Requests")) + .thenReturn(SUBSCRIPTION_LIST.get(3).getEmailType()); + when(emailTypeRepository.findAll()).thenReturn(VALID_EMAIL_TYPES); + + assertThrows(AccessDeniedException.class, () -> emailService.updateEmailSubscriptions(TEST_EMAIL, true, false, + List.of(supportRequestsDto, intersectionNotificationSummaryDto, dailyMessageCountsDto, + accessRequestDto))); + + // No additions because Access Requests required admin role and user is not + // admin + } + + // ------------------------------------------------------------------------- + // filterValidSubscriptionsForUser tests (exercised via + // updateEmailSubscriptions) + // ------------------------------------------------------------------------- + + @Test + void testFilterValidSubscriptions_UnknownCategory_ThrowsIllegalArgumentException() { + EmailType knownType = createEmailType("Known Category", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(knownType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Unknown Category", "desc", "user", + false, false, false, false, false, + true, false, false, false, false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid email category")); + } + + @Test + void testFilterValidSubscriptions_PlainUserSubscribesUserRoleType_Succeeds() { + EmailType userType = createEmailType("User Notifications", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(userType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + // subscribed=false (all frequency flags false) → nothing is added, no exception + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "User Notifications", "desc", "user", + false, false, false, false, false, + true, false, false, false, false); + + emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto)); + verify(userEmailNotificationRepository, never()).deleteAll(); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + + } + + @Test + void testFilterValidSubscriptions_PlainUserSubscribesOperatorRoleType_ThrowsAccessDeniedException() { + Role operatorRole = new Role(); + operatorRole.setName("operator"); + EmailType operatorType = new EmailType(); + operatorType.setEmailType("Operator Notifications"); + operatorType.setRequiredRole(operatorRole); + operatorType.setSupportsImmediate(true); + when(emailTypeRepository.findAll()).thenReturn(List.of(operatorType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Operator Notifications", "desc", "operator", + false, false, false, false, false, + true, false, false, false, false); + + assertThrows(AccessDeniedException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + } + + @Test + void testFilterValidSubscriptions_PlainUserSubscribesAdminRoleType_ThrowsAccessDeniedException() { + EmailType adminType = createEmailType("Admin Notifications", "desc", "admin", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(adminType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Admin Notifications", "desc", "admin", + false, false, false, false, false, + true, false, false, false, false); + + assertThrows(AccessDeniedException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + } + + @Test + void testFilterValidSubscriptions_OperatorSubscribesOperatorRoleType_Succeeds() { + Role operatorRole = new Role(); + operatorRole.setName("operator"); + EmailType operatorType = new EmailType(); + operatorType.setEmailType("Operator Notifications"); + operatorType.setRequiredRole(operatorRole); + operatorType.setSupportsImmediate(true); + when(emailTypeRepository.findAll()).thenReturn(List.of(operatorType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Operator Notifications", "desc", "operator", + false, false, false, false, false, + true, false, false, false, false); + + emailService.updateEmailSubscriptions(TEST_EMAIL, true, false, List.of(dto)); + verify(userEmailNotificationRepository, never()).deleteAll(); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + } + + @Test + void testFilterValidSubscriptions_OperatorSubscribesAdminRoleType_ThrowsAccessDeniedException() { + EmailType adminType = createEmailType("Admin Notifications", "desc", "admin", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(adminType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Admin Notifications", "desc", "admin", + false, false, false, false, false, + true, false, false, false, false); + + assertThrows(AccessDeniedException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, true, false, List.of(dto))); + } + + @Test + void testFilterValidSubscriptions_AdminSubscribesAdminRoleType_Succeeds() { + EmailType adminType = createEmailType("Admin Notifications", "desc", "admin", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(adminType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Admin Notifications", "desc", "admin", + false, false, false, false, false, + true, false, false, false, false); + + emailService.updateEmailSubscriptions(TEST_EMAIL, false, true, List.of(dto)); + verify(userEmailNotificationRepository, never()).deleteAll(); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + } + + @Test + void testFilterValidSubscriptions_AdminSubscribesOperatorRoleType_Succeeds() { + Role operatorRole = new Role(); + operatorRole.setName("operator"); + EmailType operatorType = new EmailType(); + operatorType.setEmailType("Operator Notifications"); + operatorType.setRequiredRole(operatorRole); + operatorType.setSupportsImmediate(true); + when(emailTypeRepository.findAll()).thenReturn(List.of(operatorType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Operator Notifications", "desc", "operator", + false, false, false, false, false, + true, false, false, false, false); + + emailService.updateEmailSubscriptions(TEST_EMAIL, false, true, List.of(dto)); + verify(userEmailNotificationRepository, never()).deleteAll(); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + } + + @Test + void testFilterValidSubscriptions_UnsupportedImmediateFrequency_ThrowsIllegalArgumentException() { + // Type does not support immediate, but subscription requests immediate=true + EmailType emailType = createEmailType("Daily Only", "desc", "user", + false, false, true, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(emailType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Daily Only", "desc", "user", + true, false, false, false, false, // immediate=true not supported + false, false, true, false, false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid subscription frequency")); + } + + @Test + void testFilterValidSubscriptions_UnsupportedHourlyFrequency_ThrowsIllegalArgumentException() { + EmailType emailType = createEmailType("Immediate Only", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(emailType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Immediate Only", "desc", "user", + false, true, false, false, false, // hourly=true not supported + true, false, false, false, false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid subscription frequency")); + } + + @Test + void testFilterValidSubscriptions_UnsupportedDailyFrequency_ThrowsIllegalArgumentException() { + EmailType emailType = createEmailType("Immediate Only", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(emailType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Immediate Only", "desc", "user", + false, false, true, false, false, // daily=true not supported + true, false, false, false, false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid subscription frequency")); + } + + @Test + void testFilterValidSubscriptions_UnsupportedWeeklyFrequency_ThrowsIllegalArgumentException() { + EmailType emailType = createEmailType("Immediate Only", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(emailType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); + + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Immediate Only", "desc", "user", + false, false, false, true, false, // weekly=true not supported + true, false, false, false, false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid subscription frequency")); + } + + @Test + void testFilterValidSubscriptions_UnsupportedMonthlyFrequency_ThrowsIllegalArgumentException() { + EmailType emailType = createEmailType("Immediate Only", "desc", "user", + true, false, false, false, false); + when(emailTypeRepository.findAll()).thenReturn(List.of(emailType)); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); - emailService.emailList(users, subject, text); + UserEmailNotificationDto dto = new UserEmailNotificationDto( + "Immediate Only", "desc", "user", + false, false, false, false, true, // monthly=true not supported + true, false, false, false, false); - verify(mailSender, times(2)).send(any(SimpleMailMessage.class)); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of(dto))); + assertTrue(ex.getMessage().contains("Invalid subscription frequency")); } @Test - void testGetNotificationEmailList() { - List result = emailService.getNotificationEmailList(EmailFrequency.ONCE_PER_DAY); + void testFilterValidSubscriptions_EmptySubscriptionList_ReturnsZero() { + when(emailTypeRepository.findAll()).thenReturn(List.of()); + when(userEmailNotificationRepository.findNotificationsByUser(TEST_EMAIL)).thenReturn(List.of()); - // TODO: Test underlying logic when method is further implemented - assertTrue(result.isEmpty()); + emailService.updateEmailSubscriptions(TEST_EMAIL, false, false, List.of()); + verify(userEmailNotificationRepository, never()).saveAll(anyList()); + verify(userEmailNotificationRepository, never()).deleteAll(anyList()); } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PermissionServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PermissionServiceTest.java index cebfee8fc..1fcdf51ac 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PermissionServiceTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PermissionServiceTest.java @@ -1,363 +1,416 @@ package us.dot.its.jpo.ode.api.services; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; -import us.dot.its.jpo.ode.api.models.postgres.tables.Users; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.repositories.IntersectionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; -import java.util.Arrays; -import java.util.Collections; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.util.List; +import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -public class PermissionServiceTest { +@ExtendWith(MockitoExtension.class) +class PermissionServiceTest { @Mock - private PostgresService postgresService; + private IntersectionRepository intersectionRepository; @Mock - private JwtAuthenticationToken authentication; + private RsuRepository rsuRepository; @Mock private SecurityContext securityContext; @Mock - private Jwt jwtToken; + private CvManagerAuthToken authToken; + @Spy @InjectMocks private PermissionService permissionService; + private String tokenString = "mock-token"; + @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); + void setUp() { SecurityContextHolder.setContext(securityContext); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.isAuthenticated()).thenReturn(true); - when(authentication.getName()).thenReturn("user@example.com"); - when(authentication.getToken()).thenReturn(jwtToken); - when(jwtToken.getClaimAsString("preferred_username")).thenReturn("user@example.com"); - permissionService = spy(permissionService); } - @Test - public void testIsSuperUserWhenAuthIsInvalid() { - when(permissionService.isAuthValid(authentication)).thenReturn(false); + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + RequestContextHolder.resetRequestAttributes(); + } - boolean result = permissionService.isSuperUser(); + private JwtAuthenticationToken createAuthenticatedToken(String email) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("preferred_username", email) + .build(); + JwtAuthenticationToken token = new JwtAuthenticationToken(jwt); + token.setAuthenticated(true); + return token; + } - assertFalse(result); + /** + * Sets up both Authorization and Organization headers + */ + private void setupRequestWithHeaders(String bearerToken, String organization) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + bearerToken); + if (organization != null) { + request.addHeader("Organization", organization); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } + // ==================== getAllowedIntersectionIds Tests ==================== + @Test - public void testIsSuperUserWhenUserIsNull() { - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(postgresService.findUser("testUser")).thenReturn(null); + void testGetAllowedIntersectionIdsByEmail() { + when(intersectionRepository.findAllowedIntersectionIdsByEmail("test@example.com")) + .thenReturn(List.of("123", "456", "789")); - boolean result = permissionService.isSuperUser(); + List result = permissionService.getAllowedIntersectionIdsByEmail("test@example.com"); - assertFalse(result); + assertEquals(List.of(123, 456, 789), result); + verify(intersectionRepository).findAllowedIntersectionIdsByEmail("test@example.com"); } @Test - public void testIsSuperUserWhenUserIsNotSuperUser() { - Users user = new Users(); - user.setSuper_user(false); - - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(postgresService.findUser("testUser")).thenReturn(user); + void testGetAllowedIntersectionIdsByOrganization() { + when(intersectionRepository.findIntersectionsByOrganization("TestOrg")) + .thenReturn(List.of("111", "222")); - boolean result = permissionService.isSuperUser(); + List result = permissionService.getAllowedIntersectionIdsByOrganization("TestOrg"); - assertFalse(result); + assertEquals(List.of(111, 222), result); + verify(intersectionRepository).findIntersectionsByOrganization("TestOrg"); } + // ==================== hasRole Tests ==================== + @Test - public void testIsSuperUserWhenUserIsSuperUser() { + void testHasRole_SuperUserAlwaysHasRole() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(true); - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.getUsername(securityContext.getAuthentication())) - .thenReturn("testUser"); - Users user = new Users(); - user.setSuper_user(true); + assertTrue(permissionService.hasRole(UserRole.ADMIN)); + assertTrue(permissionService.hasRole(UserRole.OPERATOR)); + assertTrue(permissionService.hasRole(UserRole.USER)); + } - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(postgresService.findUser("testUser")).thenReturn(user); + @Test + void testHasRole_WithOrganizationHeader_HasSufficientRole() { + setupRequestWithHeaders(tokenString, "TestOrg"); - boolean result = permissionService.isSuperUser(); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + when(authToken.findRoleInOrg("TestOrg")).thenReturn(Optional.of(UserRole.ADMIN)); - assertTrue(result); - } + assertTrue(permissionService.hasRole(UserRole.OPERATOR)); } @Test - public void testHasIntersection_ValidAuth_AllowedIntersection() { - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkIntersectionWithOrg("2", organizations)).thenReturn(true); + void testHasRole_WithOrganizationHeader_InsufficientRole() { + setupRequestWithHeaders(tokenString, "TestOrg"); - boolean result = permissionService.hasIntersection(2, "USER"); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + when(authToken.findRoleInOrg("TestOrg")).thenReturn(Optional.of(UserRole.USER)); - assertTrue(result); + assertFalse(permissionService.hasRole(UserRole.ADMIN)); } @Test - public void testHasIntersection_ValidAuth_AllowedIntersectionWithOrg() { + void testHasRole_WithoutOrganizationHeader_HasRoleInSomeOrg() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("org1"); - - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkIntersectionWithOrg(eq("2"), anyList())).thenReturn(true); + assertTrue(permissionService.hasRole(UserRole.OPERATOR)); + } - boolean result = permissionService.hasIntersection(2, "USER"); + @Test + void testHasRole_WithoutOrganizationHeader_NoQualifiedOrgs() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); - assertTrue(result); - } + assertFalse(permissionService.hasRole(UserRole.ADMIN)); } @Test - public void testHasIntersection_ValidAuth_NotAllowedOrg() { + void testHasRole_NotAuthenticated() { + JwtAuthenticationToken token = createAuthenticatedToken("test@example.com"); + token.setAuthenticated(false); + when(securityContext.getAuthentication()).thenReturn(token); - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("TestOrg"); + assertThrows(AccessDeniedException.class, () -> permissionService.hasRole(UserRole.USER)); + } - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkIntersectionWithOrg(eq("2"), anyList())).thenReturn(false); + // ==================== hasRoleInOrg Tests ==================== - boolean result = permissionService.hasIntersection(2, "USER"); + @Test + void testHasRoleInOrg_NotAuthenticated() { + JwtAuthenticationToken token = createAuthenticatedToken("test@example.com"); + token.setAuthenticated(false); + when(securityContext.getAuthentication()).thenReturn(token); - assertFalse(result); - } + assertThrows(AccessDeniedException.class, () -> permissionService.hasRole(UserRole.USER)); + verify(authToken, never()).getQualifiedOrgList(any()); } @Test - public void testHasIntersection_ValidAuth_AllowedSuperUser() { - when(permissionService.isSuperUser()).thenReturn(true); + void testHasRoleInOrg_SuperUser() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(true).when(authToken).isSuperUser(); - boolean result = permissionService.hasIntersection(2, "USER"); - - assertTrue(result); + assertTrue(permissionService.hasRoleInOrg("TestOrg", "ADMIN")); + verify(authToken, never()).findRoleInOrg(anyString()); } @Test - public void testHasIntersection_ValidAuth_NotAllowedIntersection() { - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkIntersectionWithOrg("2", organizations)).thenReturn(false); - - boolean result = permissionService.hasIntersection(4, "USER"); + void testHasRoleInOrg_HasExactRole() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(Optional.of(UserRole.OPERATOR)).when(authToken).findRoleInOrg("TestOrg"); - assertFalse(result); + assertTrue(permissionService.hasRoleInOrg("TestOrg", "OPERATOR")); } @Test - public void testHasIntersection_InvalidAuth() { - when(authentication.isAuthenticated()).thenReturn(false); - - boolean result = permissionService.hasIntersection(1, "USER"); + void testHasRoleInOrg_HasSufficientRole() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(Optional.of(UserRole.ADMIN)).when(authToken).findRoleInOrg("TestOrg"); - assertFalse(result); + assertTrue(permissionService.hasRoleInOrg("TestOrg", "OPERATOR")); + assertTrue(permissionService.hasRoleInOrg("TestOrg", "USER")); } @Test - public void testHasIntersection_DefaultIntersectionId() { - when(authentication.isAuthenticated()).thenReturn(true); + void testHasRoleInOrg_InsufficientRole() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(Optional.of(UserRole.USER)).when(authToken).findRoleInOrg("TestOrg"); - assertTrue(permissionService.hasIntersection(-1, "USER")); - assertTrue(permissionService.hasIntersection(null, "USER")); + assertFalse(permissionService.hasRoleInOrg("TestOrg", "OPERATOR")); + assertFalse(permissionService.hasRoleInOrg("TestOrg", "ADMIN")); } @Test - public void testHasRSU_ValidAuth_AllowedRSU() { - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkRsuWithOrg("192.168.1.1", organizations)).thenReturn(true); + void testHasRoleInOrg_NoRoleInOrganization() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + doReturn(Optional.empty()).when(authToken).findRoleInOrg("TestOrg"); - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); - - assertTrue(result); + assertFalse(permissionService.hasRoleInOrg("TestOrg", "USER")); } - @Test - public void testHasRSU_ValidAuth_AllowedIntersectionWithOrg() { + // ==================== hasIntersection Tests ==================== - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("org1"); + @Test + void testHasIntersection_NullIntersectionId() { + assertTrue(permissionService.hasIntersection(null, "USER")); + verify(intersectionRepository, never()).existsByIdAndOrganizations(anyString(), anyList()); + } - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkRsuWithOrg("192.168.1.1", organizations)).thenReturn(true); + @Test + void testHasIntersection_NegativeIntersectionId() { + assertTrue(permissionService.hasIntersection(-1, "USER")); + verify(intersectionRepository, never()).existsByIdAndOrganizations(anyString(), anyList()); + } - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); + @Test + void testHasIntersection_SuperUser() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(true); - assertTrue(result); - } + assertTrue(permissionService.hasIntersection(123, "USER")); + verify(intersectionRepository, never()).existsByIdAndOrganizations(anyString(), anyList()); } @Test - public void testHasRSU_ValidAuth_NotAllowedOrg() { - - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("TestOrg"); + void testHasIntersection_WithOrganizationHeader_HasAccess() { + when(authToken.isSuperUser()).thenReturn(false); - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkRsuWithOrg("192.168.1.1", organizations)).thenReturn(false); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Organization", "TestOrg"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(List.of("TestOrg")); + when(intersectionRepository.existsByIdAndOrganizations("123", List.of("TestOrg"))) + .thenReturn(true); - assertFalse(result); - } + assertTrue(permissionService.hasIntersection(123, "USER")); } @Test - public void testHasRSU_ValidAuth_AllowedSuperUser() { - when(permissionService.isSuperUser()).thenReturn(true); + void testHasIntersection_WithOrganizationHeader_NoAccess() { + when(authToken.isSuperUser()).thenReturn(false); - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Organization", "TestOrg"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); - assertTrue(result); + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(List.of("TestOrg")); + when(intersectionRepository.existsByIdAndOrganizations("123", List.of("TestOrg"))) + .thenReturn(false); + + assertFalse(permissionService.hasIntersection(123, "USER")); + verify(intersectionRepository).existsByIdAndOrganizations("123", List.of("TestOrg")); } @Test - public void testHasRSU_ValidAuth_NotAllowedRSU() { - List organizations = Arrays.asList("org1"); - when(postgresService.getQualifiedOrgList("user@example.com", "USER")).thenReturn(organizations); - when(postgresService.checkRsuWithOrg("192.168.1.1", organizations)).thenReturn(false); + void testHasIntersection_WithoutOrganizationHeader_HasAccessInQualifiedOrg() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(intersectionRepository.existsByIdAndOrganizations("123", List.of("TestOrg"))) + .thenReturn(true); + assertTrue(permissionService.hasIntersection(123, "OPERATOR")); + } - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); + // ==================== hasRsu Tests ==================== + + @Test + void testHasRSU_SuperUser() { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(true); - assertFalse(result); + assertTrue(permissionService.hasRsu("192.168.1.1", "USER")); + verify(rsuRepository, never()).existsByIpAndOrganizations(any(), anyList()); } @Test - public void testHasRSU_InvalidAuth() { - when(authentication.isAuthenticated()).thenReturn(false); + void testHasRSU_WithOrganizationHeader_HasAccess() throws UnknownHostException { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); - boolean result = permissionService.hasRSU("192.168.1.1", "USER"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Organization", "TestOrg"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); - assertFalse(result); + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(List.of("TestOrg")); + when(rsuRepository.existsByIpAndOrganizations(InetAddress.getByName("192.168.1.1"), List.of("TestOrg"))) + .thenReturn(true); + + assertTrue(permissionService.hasRsu("192.168.1.1", "USER")); } @Test - void testHasRoleWhenAuthIsInvalid() { - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(false); - - boolean result = permissionService.hasRole("admin"); - - assertFalse(result); + void testHasRSU_WithOrganizationHeader_NoAccess() throws UnknownHostException { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Organization", "TestOrg"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + when(authToken.getQualifiedOrgList(UserRole.USER)).thenReturn(List.of("TestOrg")); + when(rsuRepository.existsByIpAndOrganizations(InetAddress.getByName("192.168.1.1"), List.of("TestOrg"))) + .thenReturn(false); + + assertFalse(permissionService.hasRsu("192.168.1.1", "USER")); + verify(rsuRepository).existsByIpAndOrganizations(InetAddress.getByName("192.168.1.1"), + List.of("TestOrg")); } @Test - void testHasRoleWhenUserIsSuperUser() { - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(permissionService.isSuperUser()).thenReturn(true); + void testHasRSU_WithoutOrganizationHeader_HasAccessInQualifiedOrg() throws UnknownHostException { + doReturn(authToken).when(permissionService).getCvManagerAuthToken(); + when(authToken.isSuperUser()).thenReturn(false); - boolean result = permissionService.hasRole("admin"); + when(authToken.getQualifiedOrgList(UserRole.OPERATOR)).thenReturn(List.of("TestOrg")); + when(rsuRepository.existsByIpAndOrganizations(InetAddress.getByName("192.168.1.1"), List.of("TestOrg"))) + .thenReturn(true); - assertTrue(result); + assertTrue(permissionService.hasRsu("192.168.1.1", "OPERATOR")); } + // ==================== isAuthValid Tests ==================== + @Test - void testHasRoleWhenOrganizationIsProvidedAndRoleMatches() { - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.getUsername(authentication)).thenReturn("testUser"); - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("TestOrg"); - mockedStatic.when(() -> PermissionService.checkRoleAbove("admin", "admin")).thenReturn(true); + void testIsAuthValid_ValidJwtAuthentication() { + JwtAuthenticationToken token = createAuthenticatedToken("test@example.com"); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(permissionService.isSuperUser()).thenReturn(false); - when(postgresService.getUserRoleInOrg("testUser", "TestOrg")).thenReturn("admin"); + assertTrue(permissionService.isAuthValid(token)); + } - boolean result = permissionService.hasRole("admin"); + @Test + void testIsAuthValid_NotAuthenticated() { + JwtAuthenticationToken token = createAuthenticatedToken("test@example.com"); + token.setAuthenticated(false); - assertTrue(result); - } + assertFalse(permissionService.isAuthValid(token)); } @Test - void testHasRoleWhenOrganizationIsProvidedAndRoleDoesNotMatch() { - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.getUsername(authentication)).thenReturn("testUser"); - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn("TestOrg"); - mockedStatic.when(() -> PermissionService.checkRoleAbove("admin", "admin")).thenReturn(false); + void testIsAuthValid_NotJwtAuthentication() { + Authentication nonJwtAuth = mock(Authentication.class); + when(nonJwtAuth.isAuthenticated()).thenReturn(true); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(permissionService.isSuperUser()).thenReturn(false); - when(postgresService.getUserRoleInOrg("testUser", "TestOrg")).thenReturn("user"); + assertFalse(permissionService.isAuthValid(nonJwtAuth)); + } - boolean result = permissionService.hasRole("admin"); + // ==================== Static Utility Tests ==================== - assertFalse(result); - } + @Test + void testGetUsername() { + JwtAuthenticationToken token = createAuthenticatedToken("test@example.com"); + String username = PermissionService.getUsername(token); + + assertEquals("test@example.com", username); } @Test - void testHasRoleWhenNoOrganizationAndQualifiedOrgListIsNotEmpty() { - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.getUsername(authentication)).thenReturn("testUser"); - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn(null); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(permissionService.isSuperUser()).thenReturn(false); - when(postgresService.getQualifiedOrgList("testUser", "admin")) - .thenReturn(Collections.singletonList("TestOrg")); + void testGetOrganizationFromHeader_WithHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Organization", "TestOrg"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); - boolean result = permissionService.hasRole("admin"); + String organization = PermissionService.getOrganizationFromHeader(); - assertTrue(result); - } + assertEquals("TestOrg", organization); } @Test - void testHasRoleWhenNoOrganizationAndQualifiedOrgListIsEmpty() { - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.getUsername(authentication)).thenReturn("testUser"); - mockedStatic.when(PermissionService::getOrganizationFromHeader).thenReturn(null); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(permissionService.isAuthValid(authentication)).thenReturn(true); - when(permissionService.isSuperUser()).thenReturn(false); - when(postgresService.getQualifiedOrgList("testUser", "admin")).thenReturn(Collections.emptyList()); + void testGetOrganizationFromHeader_WithoutHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); - boolean result = permissionService.hasRole("admin"); + String organization = PermissionService.getOrganizationFromHeader(); - assertFalse(result); - } + assertNull(organization); } @Test - void testCheckRoleAboveAdminUser() { - // Check all combinations of requires and user roles - assertFalse(PermissionService.checkRoleAbove(null, "user")); - assertTrue(PermissionService.checkRoleAbove("user", "user")); - assertTrue(PermissionService.checkRoleAbove("operator", "user")); - assertTrue(PermissionService.checkRoleAbove("admin", "user")); - assertFalse(PermissionService.checkRoleAbove("user", "operator")); - assertTrue(PermissionService.checkRoleAbove("operator", "operator")); - assertTrue(PermissionService.checkRoleAbove("admin", "operator")); - assertFalse(PermissionService.checkRoleAbove("user", "admin")); - assertFalse(PermissionService.checkRoleAbove("operator", "admin")); - assertTrue(PermissionService.checkRoleAbove("admin", "admin")); + void testGetOrganizationFromHeader_NoRequestContext() { + RequestContextHolder.resetRequestAttributes(); + + String organization = PermissionService.getOrganizationFromHeader(); + + assertNull(organization); } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PostgresServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PostgresServiceTest.java deleted file mode 100644 index f071daac0..000000000 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/PostgresServiceTest.java +++ /dev/null @@ -1,260 +0,0 @@ -package us.dot.its.jpo.ode.api.services; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import us.dot.its.jpo.ode.api.models.postgres.derived.UserOrgRole; -import us.dot.its.jpo.ode.api.models.postgres.tables.Users; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -class PostgresServiceTest { - - @Mock - private EntityManager entityManager; - - @Mock - private TypedQuery userOrgRoleQuery; - - @Mock - private TypedQuery usersQuery; - - @Mock - private TypedQuery stringQuery; - - @InjectMocks - private PostgresService postgresService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - void testFindUserOrgRoles() { - // Arrange - String email = "test@example.com"; - List expectedRoles = Arrays.asList( - new UserOrgRole("test@example.com", "Org1", "admin"), - new UserOrgRole("test@example.com", "Org2", "user")); - - when(entityManager.createQuery(anyString(), eq(UserOrgRole.class))).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.setParameter("email", email)).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.getResultList()).thenReturn(expectedRoles); - - // Act - List result = postgresService.findUserOrgRoles(email); - - // Assert - assertEquals(expectedRoles, result); - verify(entityManager, times(1)).createQuery(anyString(), eq(UserOrgRole.class)); - } - - @Test - void testIsUserRoleAboveRequired() { - // Arrange - String email = "test@example.com"; - String organization = "TestOrg"; - String requiredRole = "admin"; - - List userOrgRoles = Arrays.asList( - new UserOrgRole(email, organization, "admin"), - new UserOrgRole(email, organization, "user")); - - when(entityManager.createQuery(anyString(), eq(UserOrgRole.class))).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.setParameter("email", email)).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.getResultList()).thenReturn(userOrgRoles); - - // Act - List result = postgresService.isUserRoleAboveRequired(email, organization, requiredRole); - - // Assert - assertEquals(userOrgRoles, result); - verify(entityManager, times(1)).createQuery(anyString(), eq(UserOrgRole.class)); - } - - @Test - void testFindUser() { - // Arrange - String email = "test@example.com"; - Users expectedUser = new Users(); - expectedUser.setEmail(email); - - when(entityManager.createQuery(anyString(), eq(Users.class))).thenReturn(usersQuery); - when(usersQuery.setParameter("email", email)).thenReturn(usersQuery); - when(usersQuery.setMaxResults(1)).thenReturn(usersQuery); - when(usersQuery.getResultList()).thenReturn(Collections.singletonList(expectedUser)); - - // Act - Users result = postgresService.findUser(email); - - // Assert - assertEquals(expectedUser, result); - verify(entityManager, times(1)).createQuery(anyString(), eq(Users.class)); - } - - @Test - void testFindUserWhenNoResults() { - // Arrange - String email = "test@example.com"; - - when(entityManager.createQuery(anyString(), eq(Users.class))).thenReturn(usersQuery); - when(usersQuery.setParameter("email", email)).thenReturn(usersQuery); - when(usersQuery.setMaxResults(1)).thenReturn(usersQuery); - when(usersQuery.getResultList()).thenReturn(Collections.emptyList()); - - // Act - Users result = postgresService.findUser(email); - - // Assert - assertNull(result); - } - - @Test - void testGetUserRoleInOrg() { - // Arrange - String email = "test@example.com"; - String organization = "Org1"; - String expectedRole = "admin"; - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("email", email)).thenReturn(stringQuery); - when(stringQuery.setParameter("organization", organization)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(Collections.singletonList(expectedRole)); - - // Act - String result = postgresService.getUserRoleInOrg(email, organization); - - // Assert - assertEquals(expectedRole, result); - } - - @Test - void testGetAllowedRsuIpByEmail() { - // Arrange - String email = "test@example.com"; - List expectedIps = Arrays.asList("192.168.1.1", "192.168.1.2"); - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("email", email)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(expectedIps); - - // Act - List result = postgresService.getAllowedRsuIpByEmail(email); - - // Assert - assertEquals(expectedIps, result); - } - - @Test - void testGetAllowedIntersectionIdsByEmail() { - // Arrange - String email = "test@example.com"; - List intersectionNumbers = Arrays.asList("1", "2"); - List expectedIds = Arrays.asList(1, 2); - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("email", email)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(intersectionNumbers); - - // Act - List result = postgresService.getAllowedIntersectionIdsByEmail(email); - - // Assert - assertEquals(expectedIds, result); - } - - @Test - void testGetAllowedIntersectionIdsByOrganization() { - // Arrange - String organization = "TestOrg"; - List intersectionNumbers = Arrays.asList("1", "2", "3"); - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("orgName", organization)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(intersectionNumbers); - - // Act - List result = postgresService.getAllowedIntersectionIdsByOrganization(organization); - - // Assert - assertEquals(Arrays.asList(1, 2, 3), result); - verify(entityManager, times(1)).createQuery(anyString(), eq(String.class)); - } - - @Test - void testCheckRsuWithOrg() { - // Arrange - String rsuIp = "192.168.1.1"; - List organizations = Arrays.asList("Org1", "Org2"); - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("allowedOrgs", organizations)).thenReturn(stringQuery); - when(stringQuery.setParameter("rsuIp", rsuIp)).thenReturn(stringQuery); - when(stringQuery.setMaxResults(1)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(Collections.singletonList(rsuIp)); - - // Act - boolean result = postgresService.checkRsuWithOrg(rsuIp, organizations); - - // Assert - assertTrue(result); - } - - @Test - void testCheckIntersectionWithOrg() { - // Arrange - String intersectionId = "1"; - List organizations = Arrays.asList("Org1", "Org2"); - - when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(stringQuery); - when(stringQuery.setParameter("allowedOrgs", organizations)).thenReturn(stringQuery); - when(stringQuery.setParameter("intersectionId", intersectionId)).thenReturn(stringQuery); - when(stringQuery.setMaxResults(1)).thenReturn(stringQuery); - when(stringQuery.getResultList()).thenReturn(Collections.singletonList(intersectionId)); - - // Act - boolean result = postgresService.checkIntersectionWithOrg(intersectionId, organizations); - - // Assert - assertTrue(result); - } - - @Test - void testGetQualifiedOrgList() { - // Arrange - String email = "test@example.com"; - String requiredRole = "admin"; - - List userOrgRoles = Arrays.asList( - new UserOrgRole(email, "Org1", "admin"), - new UserOrgRole(email, "Org2", "user")); - - when(entityManager.createQuery(anyString(), eq(UserOrgRole.class))).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.setParameter("email", email)).thenReturn(userOrgRoleQuery); - when(userOrgRoleQuery.getResultList()).thenReturn(userOrgRoles); - - // Mock PermissionService.checkRoleAbove - try (MockedStatic mockedStatic = mockStatic(PermissionService.class)) { - mockedStatic.when(() -> PermissionService.checkRoleAbove("admin", requiredRole)).thenReturn(true); - mockedStatic.when(() -> PermissionService.checkRoleAbove("user", requiredRole)).thenReturn(false); - - // Act - List result = postgresService.getQualifiedOrgList(email, requiredRole); - - // Assert - assertEquals(Collections.singletonList("Org1"), result); - verify(entityManager, times(1)).createQuery(anyString(), eq(UserOrgRole.class)); - } - } -} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuCredentialManagementServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuCredentialManagementServiceTest.java new file mode 100644 index 000000000..92af47522 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuCredentialManagementServiceTest.java @@ -0,0 +1,347 @@ +package us.dot.its.jpo.ode.api.services; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.ode.api.controllers.credentials.RsuCredentialController; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuCredential; +import us.dot.its.jpo.ode.api.repositories.OrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RsuCredentialRepository; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RsuCredentialManagementServiceTest { + + @Mock + RsuCredentialRepository mockRsuCredentialRepository; + + @Mock + OrganizationRepository mockOrganizationRepository; + + @Mock + PermissionService mockPermissionService; + + @InjectMocks + RsuCredentialManagementService rsuCredentialManagementService; + + @Test + void testCreate_Success() throws RsuCredentialManagementService.RsuCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int ownerOrganizationId = 1; + RsuCredentialController.RsuCredentialCreateRequest request = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + + Organization mockOrganization = mock(Organization.class); + when(mockOrganization.getId()).thenReturn(ownerOrganizationId); + when(mockOrganizationRepository.findByName(organization)).thenReturn(Optional.of(mockOrganization)); + + RsuCredential mockRsuCredential = new RsuCredential(); + mockRsuCredential.setNickname(nickname); + mockRsuCredential.setUsername(username); + mockRsuCredential.setPassword(password); + mockRsuCredential.setOwnerOrganization(mockOrganization); + when(mockRsuCredentialRepository.save(any())).thenReturn(mockRsuCredential); + + // Act + RsuCredential rsuCredential = rsuCredentialManagementService.create(request); + + // Assert + assertNotNull(rsuCredential); + assertEquals(nickname, rsuCredential.getNickname()); + assertEquals(username, rsuCredential.getUsername()); + assertEquals(password, rsuCredential.getPassword()); + assertEquals(1, rsuCredential.getOwnerOrganization().getId()); + verify(mockRsuCredentialRepository).save(any()); + } + + @Test + void testCreate_Failure_AlreadyExists() { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + RsuCredentialController.RsuCredentialCreateRequest request = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + + when(mockRsuCredentialRepository.existsByNickname(nickname)).thenReturn(true); + + // Act & Assert + assertThrows(RsuCredentialManagementService.RsuCredentialAlreadyExistsException.class, () -> rsuCredentialManagementService.create(request)); + } + + @Test + void testCreate_Failure_OrganizationNotFound() { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + RsuCredentialController.RsuCredentialCreateRequest request = new RsuCredentialController.RsuCredentialCreateRequest(nickname, username, password, organization); + + when(mockOrganizationRepository.findByName(organization)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialManagementService.create(request)); + } + + @Test + void testGetByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String organization = "organization"; + int organizationId = 1; + RsuCredential mockRsuCredential = new RsuCredential(); + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + mockRsuCredential.setOwnerOrganization(mockOrganization); + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(mockRsuCredential)); + + // Act + RsuCredential rsuCredential = rsuCredentialManagementService.getByNickname(nickname); + + // Assert + assertNotNull(rsuCredential); + assertEquals(mockRsuCredential, rsuCredential); + verify(mockRsuCredentialRepository).findByNickname(nickname); + } + + @Test + void testGetByNickname_Failure_NotFound() { + // Arrange + String nickname = "nickname"; + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialManagementService.getByNickname(nickname)); + } + + @Test + void testUpdate_ChangePassword_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newPassword = "mynewpassword"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setPassword(newPassword); + + RsuCredential existingCredential = new RsuCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + RsuCredential expectedCredential = new RsuCredential(); + expectedCredential.setNickname(nickname); + expectedCredential.setUsername(username); + expectedCredential.setPassword(newPassword); + expectedCredential.setOwnerOrganization(mockOrganization); + + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockRsuCredentialRepository.save(any())).thenReturn(expectedCredential); + + // Act + RsuCredential updatedCredential = rsuCredentialManagementService.update(rsuCredentialPatch); + + // Assert + assertNotNull(updatedCredential); + assertEquals(nickname, updatedCredential.getNickname()); + assertEquals(username, updatedCredential.getUsername()); + assertEquals(newPassword, updatedCredential.getPassword()); + verify(mockRsuCredentialRepository).findByNickname(nickname); + verify(mockRsuCredentialRepository).save(any()); + } + + @Test + void testUpdate_ChangeOrganization_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String oldOrganization = "oldOrganization"; + int oldOrganizationId = 1; + String newOrganization = "newOrganization"; + int newOrganizationId = 2; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setOrganization(newOrganization); + + RsuCredential existingCredential = new RsuCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOldOrganization = mock(Organization.class); + lenient().when(mockOldOrganization.getId()).thenReturn(oldOrganizationId); + lenient().when(mockOldOrganization.getName()).thenReturn(oldOrganization); + existingCredential.setOwnerOrganization(mockOldOrganization); + + RsuCredential expectedCredential = new RsuCredential(); + expectedCredential.setNickname(nickname); + expectedCredential.setUsername(username); + expectedCredential.setPassword(password); + + Organization mockNewOrganization = mock(Organization.class); + lenient().when(mockNewOrganization.getId()).thenReturn(newOrganizationId); + lenient().when(mockNewOrganization.getName()).thenReturn(newOrganization); + expectedCredential.setOwnerOrganization(mockNewOrganization); + + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockRsuCredentialRepository.save(any())).thenReturn(expectedCredential); + + when(mockOrganizationRepository.findByName(newOrganization)).thenReturn(Optional.of(mockNewOrganization)); + + // Act + RsuCredential updatedCredential = rsuCredentialManagementService.update(rsuCredentialPatch); + + // Assert + assertNotNull(updatedCredential); + assertEquals(nickname, updatedCredential.getNickname()); + assertEquals(username, updatedCredential.getUsername()); + assertEquals(password, updatedCredential.getPassword()); + assertEquals(newOrganizationId, updatedCredential.getOwnerOrganization().getId()); + verify(mockRsuCredentialRepository).findByNickname(nickname); + verify(mockRsuCredentialRepository).save(any()); + } + + @Test + void testUpdate_ChangeUsername_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newUsername = "newUsername"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setUsername(newUsername); + + RsuCredential existingCredential = new RsuCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + RsuCredential expectedCredential = new RsuCredential(); + expectedCredential.setNickname(nickname); + expectedCredential.setUsername(newUsername); + expectedCredential.setPassword(password); + expectedCredential.setOwnerOrganization(mockOrganization); + + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + + when(mockRsuCredentialRepository.save(any())).thenReturn(expectedCredential); + + // Act + RsuCredential updatedCredential = rsuCredentialManagementService.update(rsuCredentialPatch); + + // Assert + assertNotNull(updatedCredential); + assertEquals(nickname, updatedCredential.getNickname()); + assertEquals(newUsername, updatedCredential.getUsername()); + assertEquals(password, updatedCredential.getPassword()); + assertEquals(organizationId, updatedCredential.getOwnerOrganization().getId()); + verify(mockRsuCredentialRepository).findByNickname(nickname); + verify(mockRsuCredentialRepository).save(any()); + } + + @Test + void testUpdate_ChangePassword_Failure_CredentialNotFound() { + // Arrange + String nickname = "nickname"; + String newPassword = "mynewpassword"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setPassword(newPassword); + + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialManagementService.update(rsuCredentialPatch)); + } + + @Test + void testUpdate_ChangeOrganization_Failure_OrganizationNotFound() { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newOrganization = "newOrganization"; + RsuCredentialController.RsuCredentialPatch rsuCredentialPatch = new RsuCredentialController.RsuCredentialPatch(nickname); + rsuCredentialPatch.setOrganization(newOrganization); + + RsuCredential existingCredential = new RsuCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockOrganizationRepository.findByName(newOrganization)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialManagementService.update(rsuCredentialPatch)); + } + + @Test + void testDeleteByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String organization = "organization"; + int organizationId = 1; + + RsuCredential existingCredential = new RsuCredential(); + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + + // Act + rsuCredentialManagementService.deleteByNickname(nickname); + + // Assert + verify(mockRsuCredentialRepository).delete(existingCredential); + } + + @Test + void testDeleteByNickname_Failure_NotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + when(mockRsuCredentialRepository.findByNickname(nickname)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> rsuCredentialManagementService.deleteByNickname(nickname)); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuManagementServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuManagementServiceTest.java new file mode 100644 index 000000000..5111ec61e --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuManagementServiceTest.java @@ -0,0 +1,903 @@ +package us.dot.its.jpo.ode.api.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import us.dot.its.jpo.ode.api.mappers.RsuInfoMapper; +import us.dot.its.jpo.ode.api.mappers.RsuPatchMapper; +import us.dot.its.jpo.ode.api.models.devices.RsuInfoDto; +import us.dot.its.jpo.ode.api.models.devices.management.ModifyRsuAllowedSelections; +import us.dot.its.jpo.ode.api.models.devices.management.RsuPatch; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.models.SimplePosition; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuCredential; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuModel; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuOrganization; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpCredential; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpProtocol; +import us.dot.its.jpo.ode.api.repositories.ConsecutiveFirmwareUpgradeFailureRepository; +import us.dot.its.jpo.ode.api.repositories.MaxRetryLimitReachedInstanceRepository; +import us.dot.its.jpo.ode.api.repositories.OrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.PingRepository; +import us.dot.its.jpo.ode.api.repositories.RsuCredentialRepository; +import us.dot.its.jpo.ode.api.repositories.RsuIntersectionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RsuModelRepository; +import us.dot.its.jpo.ode.api.repositories.RsuOptionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; +import us.dot.its.jpo.ode.api.repositories.ScmsHealthRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpCredentialRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpMsgfwdConfigRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpProtocolRepository; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashSet; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RsuManagementServiceTest { + + @Mock + private ConsecutiveFirmwareUpgradeFailureRepository consecutiveFirmwareUpgradeFailureRepository; + + @Mock + private MaxRetryLimitReachedInstanceRepository maxRetryLimitReachedInstanceRepository; + + @Mock + private OrganizationRepository organizationRepository; + + @Mock + private PermissionService permissionService; + + @Mock + private PingRepository pingRepository; + + @Mock + private RsuCredentialRepository rsuCredentialRepository; + + @Mock + private RsuIntersectionRepository rsuIntersectionRepository; + + @Mock + private RsuOrganizationRepository rsuOrganizationRepository; + + @Mock + private RsuModelRepository rsuModelRepository; + + @Mock + private RsuRepository rsuRepository; + + @Mock + private RsuOptionRepository rsuOptionRepository; + + @Mock + private ScmsHealthRepository scmsHealthRepository; + + @Mock + private SnmpCredentialRepository snmpCredentialRepository; + + @Mock + private SnmpMsgfwdConfigRepository snmpMsgfwdConfigRepository; + + @Mock + private SnmpProtocolRepository snmpProtocolRepository; + + @Mock + private RsuInfoMapper rsuMapper; + + @Mock + private RsuPatchMapper rsuPatchMapper; + + @Mock + private CvManagerAuthToken authToken; + + @InjectMocks + private RsuManagementService rsuManagementService; + + // ==================== GET RSU INFO TESTS ==================== + + @Test + void testGetRsuInfo_Success() throws UnknownHostException { + String ipAddress = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(ipAddress); + + Rsu mockRsu = new Rsu(); + RsuInfoDto mockDto = new RsuInfoDto( + ipAddress, + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU123", + "SCMS123", + "Model X", + "ssh-group", + "snmp-group", + "v3", + Arrays.asList("Org1", "Org2"), + Boolean.TRUE, + Boolean.TRUE); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(mockRsu); + when(rsuMapper.toDto(mockRsu)).thenReturn(mockDto); + + RsuInfoDto result = rsuManagementService.getRsuInfo(ipAddress); + + assertNotNull(result); + assertEquals(ipAddress, result.getIpv4Address()); + assertEquals(123.4, result.getMilepost()); + assertEquals("I-25", result.getPrimaryRoute()); + verify(rsuRepository).findByIpv4Address(inetAddress); + verify(rsuMapper).toDto(mockRsu); + } + + @Test + void testGetRsuInfo_NotFound() throws UnknownHostException { + String ipAddress = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(ipAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(null); + + RsuInfoDto result = rsuManagementService.getRsuInfo(ipAddress); + + assertNull(result); + verify(rsuRepository).findByIpv4Address(inetAddress); + verify(rsuMapper, never()).toDto(any()); + } + + @Test + void testGetRsuInfo_InvalidIpAddress() { + String invalidIpAddress = "invalid-ip"; + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.getRsuInfo(invalidIpAddress)); + + assertTrue(exception.getMessage().contains("Invalid IP address")); + assertInstanceOf(UnknownHostException.class, exception.getCause()); + verify(rsuRepository, never()).findByIpv4Address(any()); + } + + // ==================== GET ALL RSU INFO TESTS ==================== + + @Test + void testGetAllRsuInfo_Success() { + String orgName = "TestOrg"; + String search = "Search Term"; + Pageable pageable = PageRequest.of(0, 10); + + Rsu rsu1 = new Rsu(); + Rsu rsu2 = new Rsu(); + List rsuList = Arrays.asList(rsu1, rsu2); + Page rsuPage = new PageImpl<>(rsuList, pageable, 2); + + RsuInfoDto dto1 = new RsuInfoDto( + "192.168.1.100", + new SimplePosition(39.7392, -105.0844), + 123.4, + "I-25", + "RSU1", + "SCMS1", + "Model X", + "ssh1", + "snmp1", + "v3", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + RsuInfoDto dto2 = new RsuInfoDto( + "192.168.1.101", + new SimplePosition(39.7400, -105.0850), + 124.5, + "I-70", + "RSU2", + "SCMS2", + "Model Y", + "ssh2", + "snmp2", + "v2c", + Arrays.asList("TestOrg"), + Boolean.TRUE, + Boolean.TRUE); + + when(rsuRepository.findAllByOrganization(orgName, search, pageable)).thenReturn(rsuPage); + when(rsuMapper.toDto(rsu1)).thenReturn(dto1); + when(rsuMapper.toDto(rsu2)).thenReturn(dto2); + + Page result = rsuManagementService.getAllRsuInfo(orgName, search, pageable); + + assertNotNull(result); + assertEquals(2, result.getTotalElements()); + assertEquals(2, result.getContent().size()); + assertEquals("192.168.1.100", result.getContent().get(0).getIpv4Address()); + assertEquals("192.168.1.101", result.getContent().get(1).getIpv4Address()); + verify(rsuRepository).findAllByOrganization(orgName, search, pageable); + verify(rsuMapper, times(2)).toDto(any(Rsu.class)); + } + + @Test + void testGetAllRsuInfo_EmptyResult() { + String orgName = "EmptyOrg"; + String search = "Search Term"; + Pageable pageable = PageRequest.of(0, 10); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + when(rsuRepository.findAllByOrganization(orgName, search, pageable)).thenReturn(emptyPage); + + Page result = rsuManagementService.getAllRsuInfo(orgName, search, pageable); + + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + verify(rsuRepository).findAllByOrganization(orgName, search, pageable); + verify(rsuMapper, never()).toDto(any()); + } + + // ==================== GET ALLOWED SELECTIONS TESTS ==================== + + @Test + void testGetAllowedSelections_Success() { + + List primaryRoutes = Arrays.asList("I-25", "I-70", "US-36"); + + List rsuModels = Arrays.asList( + createRsuModelProjection("Commsignia", "ITS-RS4-M"), + createRsuModelProjection("Yunex", "RSU-2X")); + + List sshCredentials = Arrays.asList("ssh-group-1", "ssh-group-2"); + List snmpCredentials = Arrays.asList("snmp-group-1", "snmp-group-2"); + List snmpVersions = Arrays.asList("v2c", "v3"); + + when(rsuRepository.findAllPrimaryRoutes()).thenReturn(primaryRoutes); + when(rsuRepository.findAllRsuModels()).thenReturn(rsuModels); + when(rsuCredentialRepository.findAllNicknames()).thenReturn(sshCredentials); + when(snmpCredentialRepository.findAllNicknames()).thenReturn(snmpCredentials); + when(snmpProtocolRepository.findAllNicknames()).thenReturn(snmpVersions); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(Arrays.asList("Org1", "Org2")); + + ModifyRsuAllowedSelections result = rsuManagementService.getAllowedSelections(authToken); + + assertNotNull(result); + + assertEquals(3, result.getPrimaryRoutes().size()); + assertTrue(result.getPrimaryRoutes().contains("I-25")); + + assertEquals(2, result.getRsuModels().size()); + assertTrue(result.getRsuModels().contains("Commsignia ITS-RS4-M")); + assertTrue(result.getRsuModels().contains("Yunex RSU-2X")); + + assertEquals(2, result.getSshCredentialGroups().size()); + assertTrue(result.getSshCredentialGroups().contains("ssh-group-1")); + + assertEquals(2, result.getSnmpCredentialGroups().size()); + assertTrue(result.getSnmpCredentialGroups().contains("snmp-group-1")); + + assertEquals(2, result.getSnmpVersionGroups().size()); + assertTrue(result.getSnmpVersionGroups().contains("v2c")); + + assertEquals(2, result.getOrganizations().size()); + assertTrue(result.getOrganizations().contains("Org1")); + assertTrue(result.getOrganizations().contains("Org2")); + + verify(rsuRepository).findAllPrimaryRoutes(); + verify(rsuRepository).findAllRsuModels(); + verify(rsuCredentialRepository).findAllNicknames(); + verify(snmpCredentialRepository).findAllNicknames(); + verify(snmpProtocolRepository).findAllNicknames(); + } + + @Test + void testGetAllowedSelections_EmptyResults() { + + when(rsuRepository.findAllPrimaryRoutes()).thenReturn(List.of()); + when(rsuRepository.findAllRsuModels()).thenReturn(List.of()); + when(rsuCredentialRepository.findAllNicknames()).thenReturn(List.of()); + when(snmpCredentialRepository.findAllNicknames()).thenReturn(List.of()); + when(snmpProtocolRepository.findAllNicknames()).thenReturn(List.of()); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + + ModifyRsuAllowedSelections result = rsuManagementService.getAllowedSelections(authToken); + + assertNotNull(result); + assertTrue(result.getPrimaryRoutes().isEmpty()); + assertTrue(result.getRsuModels().isEmpty()); + assertTrue(result.getSshCredentialGroups().isEmpty()); + assertTrue(result.getSnmpCredentialGroups().isEmpty()); + assertTrue(result.getSnmpVersionGroups().isEmpty()); + assertTrue(result.getOrganizations().isEmpty()); + } + + // ==================== MODIFY RSU TESTS ==================== + + @Test + void testModifyRsu_Success() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setMilepost(150.0); + patch.setPrimaryRoute("I-70"); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setMilepost(123.4); + existingRsu.setPrimaryRoute("I-25"); + existingRsu.setRsuOrganizations(new HashSet<>()); + + RsuInfoDto expectedDto = new RsuInfoDto( + rsuIp, + new SimplePosition(39.7392, -105.0844), + 150.0, + "I-70", + "RSU123", + "SCMS123", + "Model X", + "ssh-group", + "snmp-group", + "v3", + Arrays.asList("Org1"), + Boolean.TRUE, + Boolean.TRUE); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + doNothing().when(rsuPatchMapper).updateRsuFromPatch(patch, existingRsu); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(expectedDto); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("Org1")); + + RsuInfoDto result = rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + assertNotNull(result); + assertEquals(150.0, result.getMilepost()); + assertEquals("I-70", result.getPrimaryRoute()); + verify(rsuRepository).findByIpv4Address(inetAddress); + verify(rsuPatchMapper).updateRsuFromPatch(patch, existingRsu); + verify(rsuRepository).save(existingRsu); + verify(rsuMapper).toDto(existingRsu); + } + + @Test + void testModifyRsu_WithModelUpdate() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setModel("Yunex RSU-2X"); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + RsuModel newModel = new RsuModel(); + newModel.setName("RSU-2X"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuModelRepository.findByNameAndManufacturerName("RSU-2X", "Yunex")) + .thenReturn(Optional.of(newModel)); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("Org1")); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuModelRepository).findByNameAndManufacturerName("RSU-2X", "Yunex"); + verify(rsuRepository).save(existingRsu); + } + + @Test + void testModifyRsu_WithCredentialUpdates() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setSshCredentialGroup("ssh-group-new"); + patch.setSnmpCredentialGroup("snmp-group-new"); + patch.setSnmpVersionGroup("v3"); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + RsuCredential sshCred = new RsuCredential(); + SnmpCredential snmpCred = new SnmpCredential(); + SnmpProtocol snmpProtocol = new SnmpProtocol(); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuCredentialRepository.findByNickname("ssh-group-new")).thenReturn(Optional.of(sshCred)); + when(snmpCredentialRepository.findByNickname("snmp-group-new")).thenReturn(Optional.of(snmpCred)); + when(snmpProtocolRepository.findByNickname("v3")).thenReturn(Optional.of(snmpProtocol)); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("Org1")); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuCredentialRepository).findByNickname("ssh-group-new"); + verify(snmpCredentialRepository).findByNickname("snmp-group-new"); + verify(snmpProtocolRepository).findByNickname("v3"); + } + + @Test + void testModifyRsu_RsuNotFound() throws UnknownHostException { + String rsuIp = "192.168.1.123"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + RsuPatch patch = new RsuPatch(); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(null); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertTrue(exception.getMessage().contains("RSU not found")); + verify(rsuRepository, never()).save(any()); + } + + @Test + void testModifyRsu_InvalidIpAddress() { + String invalidIp = "invalid-ip"; + RsuPatch patch = new RsuPatch(); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(invalidIp, patch, authToken)); + + assertTrue(exception.getMessage().contains("Invalid IP address")); + verify(rsuRepository, never()).save(any()); + } + + @Test + void testModifyRsu_ModelNotFound() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setModel("Unknown Model"); + + Rsu existingRsu = new Rsu(); + existingRsu.setRsuOrganizations(new HashSet<>()); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuModelRepository.findByNameAndManufacturerName(anyString(), anyString())) + .thenReturn(Optional.empty()); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertTrue(exception.getMessage().contains("Model not found")); + } + + @Test + void testModifyRsu_InvalidModelFormat() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setModel("InvalidFormat"); + + Rsu existingRsu = new Rsu(); + existingRsu.setRsuOrganizations(new HashSet<>()); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertTrue(exception.getMessage().contains("Invalid model format")); + } + + // ==================== HANDLE ORGANIZATION CHANGES TESTS ==================== + + @Test + void testHandleOrganizationChanges_AddOrganizations_Success() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("Org1", "Org2")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + Organization org1 = new Organization(); + org1.setName("Org1"); + Organization org2 = new Organization(); + org2.setName("Org2"); + + List authorizedOrgs = Arrays.asList("Org1", "Org2", "Org3"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.existsByIpAndOrganizations(inetAddress, List.of("Org1"))) + .thenReturn(false); + when(rsuRepository.existsByIpAndOrganizations(inetAddress, List.of("Org2"))) + .thenReturn(false); + when(organizationRepository.findByName("Org1")).thenReturn(Optional.of(org1)); + when(organizationRepository.findByName("Org2")).thenReturn(Optional.of(org2)); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, times(2)).save(any(RsuOrganization.class)); + verify(organizationRepository).findByName("Org1"); + verify(organizationRepository).findByName("Org2"); + } + + @Test + void testHandleOrganizationChanges_AddOrganizations_AlreadyExists() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("Org1")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.existsByIpAndOrganizations(inetAddress, List.of("Org1"))) + .thenReturn(true); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + verify(organizationRepository, never()).findByName(anyString()); + } + + @Test + void testHandleOrganizationChanges_AddOrganizations_Unauthorized() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("UnauthorizedOrg")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1", "Org2"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("User does not have permission to add RSU to organization(s)")); + assertTrue(exception.getMessage().contains("UnauthorizedOrg")); + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_AddOrganizations_OrganizationNotFound() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("NonExistentOrg")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("NonExistentOrg"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.existsByIpAndOrganizations(inetAddress, List.of("NonExistentOrg"))) + .thenReturn(false); + when(organizationRepository.findByName("NonExistentOrg")).thenReturn(Optional.empty()); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("Organization not found: NonExistentOrg")); + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_RemoveOrganizations_Success() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToRemove(Arrays.asList("Org1", "Org2")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + Organization org1 = new Organization(); + org1.setName("Org1"); + Organization org2 = new Organization(); + org2.setName("Org2"); + + RsuOrganization rsuOrg1 = new RsuOrganization(); + rsuOrg1.setRsu(existingRsu); + rsuOrg1.setOrganization(org1); + + RsuOrganization rsuOrg2 = new RsuOrganization(); + rsuOrg2.setRsu(existingRsu); + rsuOrg2.setOrganization(org2); + + List authorizedOrgs = Arrays.asList("Org1", "Org2", "Org3"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOrganizationRepository.findByRsuIpv4AddressAndOrganization_Name(inetAddress, "Org1")) + .thenReturn(Optional.of(rsuOrg1)); + when(rsuOrganizationRepository.findByRsuIpv4AddressAndOrganization_Name(inetAddress, "Org2")) + .thenReturn(Optional.of(rsuOrg2)); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository).delete(rsuOrg1); + verify(rsuOrganizationRepository).delete(rsuOrg2); + } + + @Test + void testHandleOrganizationChanges_RemoveOrganizations_NotFound() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToRemove(Arrays.asList("Org1")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOrganizationRepository.findByRsuIpv4AddressAndOrganization_Name(inetAddress, "Org1")) + .thenReturn(Optional.empty()); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, never()).delete(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_RemoveOrganizations_Unauthorized() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToRemove(Arrays.asList("UnauthorizedOrg")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1", "Org2"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("User does not have permission to remove RSU from organization(s)")); + assertTrue(exception.getMessage().contains("UnauthorizedOrg")); + verify(rsuOrganizationRepository, never()).delete(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_AddAndRemoveOrganizations() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("NewOrg")); + patch.setOrganizationsToRemove(Arrays.asList("OldOrg")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + Organization newOrg = new Organization(); + newOrg.setName("NewOrg"); + Organization oldOrg = new Organization(); + oldOrg.setName("OldOrg"); + + RsuOrganization rsuOrgToRemove = new RsuOrganization(); + rsuOrgToRemove.setRsu(existingRsu); + rsuOrgToRemove.setOrganization(oldOrg); + + List authorizedOrgs = Arrays.asList("NewOrg", "OldOrg"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.existsByIpAndOrganizations(inetAddress, List.of("NewOrg"))) + .thenReturn(false); + when(organizationRepository.findByName("NewOrg")).thenReturn(Optional.of(newOrg)); + when(rsuOrganizationRepository.findByRsuIpv4AddressAndOrganization_Name(inetAddress, "OldOrg")) + .thenReturn(Optional.of(rsuOrgToRemove)); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository).save(any(RsuOrganization.class)); + verify(rsuOrganizationRepository).delete(rsuOrgToRemove); + } + + @Test + void testHandleOrganizationChanges_AddMultipleOrganizations_PartiallyUnauthorized() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(Arrays.asList("Org1", "UnauthorizedOrg1", "UnauthorizedOrg2")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("UnauthorizedOrg1")); + assertTrue(exception.getMessage().contains("UnauthorizedOrg2")); + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_RemoveMultipleOrganizations_PartiallyUnauthorized() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToRemove(Arrays.asList("Org1", "UnauthorizedOrg1", "UnauthorizedOrg2")); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + List authorizedOrgs = Arrays.asList("Org1"); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(authorizedOrgs); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuManagementService.modifyRsu(rsuIp, patch, authToken)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("UnauthorizedOrg1")); + assertTrue(exception.getMessage().contains("UnauthorizedOrg2")); + verify(rsuOrganizationRepository, never()).delete(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_NoOrganizationChanges() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + // No organization changes + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + verify(rsuOrganizationRepository, never()).delete(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_EmptyAddList() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToAdd(List.of()); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, never()).save(any(RsuOrganization.class)); + } + + @Test + void testHandleOrganizationChanges_EmptyRemoveList() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setOrganizationsToRemove(List.of()); + + Rsu existingRsu = new Rsu(); + existingRsu.setIpv4Address(inetAddress); + existingRsu.setRsuOrganizations(new HashSet<>()); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuRepository.save(existingRsu)).thenReturn(existingRsu); + when(rsuMapper.toDto(existingRsu)).thenReturn(null); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + + rsuManagementService.modifyRsu(rsuIp, patch, authToken); + + verify(rsuOrganizationRepository, never()).delete(any(RsuOrganization.class)); + } + + // ==================== HELPER METHODS ==================== + + private RsuRepository.RsuModelProjection createRsuModelProjection(String manufacturer, String model) { + return new RsuRepository.RsuModelProjection() { + @Override + public String getManufacturer() { + return manufacturer; + } + + @Override + public String getModel() { + return model; + } + }; + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuOptionManagementServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuOptionManagementServiceTest.java new file mode 100644 index 000000000..cac3cada9 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuOptionManagementServiceTest.java @@ -0,0 +1,375 @@ +package us.dot.its.jpo.ode.api.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import us.dot.its.jpo.ode.api.models.devices.management.RsuPatch; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.models.postgres.tables.RsuOption; +import us.dot.its.jpo.ode.api.repositories.RsuOptionRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RsuOptionManagementServiceTest { + + @Mock + private RsuRepository rsuRepository; + + @Mock + private RsuOptionRepository rsuOptionRepository; + + @InjectMocks + private RsuOptionManagementService rsuOptionManagementService; + + // ==================== CREATE NEW RSU OPTION TESTS ==================== + + @Test + void testModifyRsuOption_CreateNewRsuOption_BothFields() throws UnknownHostException { + String rsuIp = "192.168.1.100"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(true); + patch.setSnmpMonitoring(true); + + Rsu existingRsu = new Rsu(); + existingRsu.setId(1); + existingRsu.setIpv4Address(inetAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(1)).thenReturn(Optional.empty()); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that RsuOption was created and saved with correct values + verify(rsuOptionRepository).findByRsuId(1); + + ArgumentCaptor optionCaptor = ArgumentCaptor.forClass(RsuOption.class); + verify(rsuOptionRepository).save(optionCaptor.capture()); + + RsuOption savedOption = optionCaptor.getValue(); + assertNotNull(savedOption); + assertEquals(true, savedOption.getTimDeposit()); + assertEquals(true, savedOption.getSnmpMonitoring()); + assertEquals(existingRsu, savedOption.getRsu()); + } + + @Test + void testModifyRsuOption_CreateNewRsuOption_TimDepositOnly() throws UnknownHostException { + String rsuIp = "192.168.1.101"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(true); + // snmpMonitoring is not set + + Rsu existingRsu = new Rsu(); + existingRsu.setId(2); + existingRsu.setIpv4Address(inetAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(2)).thenReturn(Optional.empty()); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that RsuOption was created with tim_deposit set and default for snmp_monitoring + verify(rsuOptionRepository).findByRsuId(2); + + ArgumentCaptor optionCaptor = ArgumentCaptor.forClass(RsuOption.class); + verify(rsuOptionRepository).save(optionCaptor.capture()); + + RsuOption savedOption = optionCaptor.getValue(); + assertNotNull(savedOption); + assertEquals(true, savedOption.getTimDeposit()); + assertEquals(false, savedOption.getSnmpMonitoring()); // default value + assertEquals(existingRsu, savedOption.getRsu()); + } + + @Test + void testModifyRsuOption_CreateNewRsuOption_SnmpMonitoringOnly() throws UnknownHostException { + String rsuIp = "192.168.1.102"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setSnmpMonitoring(true); + // timDeposit is not set + + Rsu existingRsu = new Rsu(); + existingRsu.setId(3); + existingRsu.setIpv4Address(inetAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(3)).thenReturn(Optional.empty()); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that RsuOption was created with snmp_monitoring set and default for tim_deposit + verify(rsuOptionRepository).findByRsuId(3); + + ArgumentCaptor optionCaptor = ArgumentCaptor.forClass(RsuOption.class); + verify(rsuOptionRepository).save(optionCaptor.capture()); + + RsuOption savedOption = optionCaptor.getValue(); + assertNotNull(savedOption); + assertEquals(false, savedOption.getTimDeposit()); // default value + assertEquals(true, savedOption.getSnmpMonitoring()); + assertEquals(existingRsu, savedOption.getRsu()); + } + + // ==================== UPDATE EXISTING RSU OPTION TESTS ==================== + + @Test + void testModifyRsuOption_UpdateExistingOption_BothFields() throws UnknownHostException { + String rsuIp = "192.168.1.103"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(false); + patch.setSnmpMonitoring(true); + + Rsu existingRsu = new Rsu(); + existingRsu.setId(4); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(4); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); // old value + existingOption.setSnmpMonitoring(false); // old value + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(4)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that existing RsuOption was updated + verify(rsuOptionRepository).findByRsuId(4); + verify(rsuOptionRepository).save(existingOption); + assertEquals(false, existingOption.getTimDeposit()); + assertEquals(true, existingOption.getSnmpMonitoring()); + } + + @Test + void testModifyRsuOption_UpdateExistingOption_TimDepositOnly() throws UnknownHostException { + String rsuIp = "192.168.1.104"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(false); + // snmpMonitoring is not set, should not change existing value + + Rsu existingRsu = new Rsu(); + existingRsu.setId(5); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(5); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); // should be updated + existingOption.setSnmpMonitoring(true); // should remain unchanged + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(5)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that only timDeposit was updated + verify(rsuOptionRepository).findByRsuId(5); + verify(rsuOptionRepository).save(existingOption); + assertEquals(false, existingOption.getTimDeposit()); // updated + assertEquals(true, existingOption.getSnmpMonitoring()); // unchanged + } + + @Test + void testModifyRsuOption_UpdateExistingOption_SnmpMonitoringOnly() throws UnknownHostException { + String rsuIp = "192.168.1.105"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setSnmpMonitoring(false); + // timDeposit is not set, should not change existing value + + Rsu existingRsu = new Rsu(); + existingRsu.setId(6); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(6); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); // should remain unchanged + existingOption.setSnmpMonitoring(true); // should be updated + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(6)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that only snmpMonitoring was updated + verify(rsuOptionRepository).findByRsuId(6); + verify(rsuOptionRepository).save(existingOption); + assertEquals(true, existingOption.getTimDeposit()); // unchanged + assertEquals(false, existingOption.getSnmpMonitoring()); // updated + } + + @Test + void testModifyRsuOption_UpdateExistingOption_NoChanges() throws UnknownHostException { + String rsuIp = "192.168.1.106"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(true); + patch.setSnmpMonitoring(false); + + Rsu existingRsu = new Rsu(); + existingRsu.setId(7); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(7); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); // same as patch + existingOption.setSnmpMonitoring(false); // same as patch + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(7)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that no save occurred since values are unchanged + verify(rsuOptionRepository).findByRsuId(7); + verify(rsuOptionRepository, never()).save(any()); + } + + // ==================== EARLY RETURN TESTS ==================== + + @Test + void testModifyRsuOption_NoOptionsProvided_EarlyReturn() throws UnknownHostException { + String rsuIp = "192.168.1.107"; + + RsuPatch patch = new RsuPatch(); + // No timDeposit or snmpMonitoring set + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that repositories were never accessed due to early return + verify(rsuRepository, never()).findByIpv4Address(any()); + verify(rsuOptionRepository, never()).findByRsuId(any()); + verify(rsuOptionRepository, never()).save(any()); + } + + // ==================== ERROR HANDLING TESTS ==================== + + @Test + void testModifyRsuOption_InvalidIpAddress_ThrowsBadRequest() { + String invalidIp = "invalid-ip"; + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(true); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { + rsuOptionManagementService.modifyRsuOption(invalidIp, patch); + }); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Invalid IP address")); + verify(rsuOptionRepository, never()).findByRsuId(any()); + verify(rsuOptionRepository, never()).save(any()); + } + + @Test + void testModifyRsuOption_RsuNotFound_ThrowsNotFound() throws UnknownHostException { + String rsuIp = "192.168.1.108"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(true); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + }); + + assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); + assertTrue(exception.getReason().contains("RSU not found")); + verify(rsuOptionRepository, never()).findByRsuId(any()); + verify(rsuOptionRepository, never()).save(any()); + } + + // ==================== EDGE CASE TESTS ==================== + + @Test + void testModifyRsuOption_SetBothFieldsToFalse() throws UnknownHostException { + String rsuIp = "192.168.1.109"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(false); + patch.setSnmpMonitoring(false); + + Rsu existingRsu = new Rsu(); + existingRsu.setId(9); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(9); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); + existingOption.setSnmpMonitoring(true); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(9)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that both fields were set to false + verify(rsuOptionRepository).save(existingOption); + assertEquals(false, existingOption.getTimDeposit()); + assertEquals(false, existingOption.getSnmpMonitoring()); + } + + @Test + void testModifyRsuOption_ToggleValues() throws UnknownHostException { + String rsuIp = "192.168.1.110"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + RsuPatch patch = new RsuPatch(); + patch.setTimDeposit(false); + patch.setSnmpMonitoring(true); + + Rsu existingRsu = new Rsu(); + existingRsu.setId(10); + existingRsu.setIpv4Address(inetAddress); + + RsuOption existingOption = new RsuOption(); + existingOption.setId(10); + existingOption.setRsu(existingRsu); + existingOption.setTimDeposit(true); + existingOption.setSnmpMonitoring(false); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(existingRsu); + when(rsuOptionRepository.findByRsuId(10)).thenReturn(Optional.of(existingOption)); + + rsuOptionManagementService.modifyRsuOption(rsuIp, patch); + + // Verify that values were toggled + verify(rsuOptionRepository).save(existingOption); + assertEquals(false, existingOption.getTimDeposit()); + assertEquals(true, existingOption.getSnmpMonitoring()); + } +} + diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeContextServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeContextServiceTest.java new file mode 100644 index 000000000..b888e1c48 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeContextServiceTest.java @@ -0,0 +1,97 @@ +package us.dot.its.jpo.ode.api.services; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; + +@ExtendWith(MockitoExtension.class) +class RsuUpgradeContextServiceTest { + + @Mock + private RsuRepository rsuRepository; + + @InjectMocks + private RsuUpgradeContextService rsuUpgradeContextService; + + @Test + void testHasCompleteRsuData_TrueWhenRsuExists() throws UnknownHostException { + String rsuIp = "10.0.0.10"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + Rsu rsu = new Rsu(); + rsu.setIpv4Address(inetAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(rsu); + + boolean result = rsuUpgradeContextService.hasCompleteRsuData(rsuIp); + + assertTrue(result); + verify(rsuRepository).findByIpv4Address(inetAddress); + } + + @Test + void testHasCompleteRsuData_FalseWhenRsuMissing() throws UnknownHostException { + String rsuIp = "10.0.0.10"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(null); + + boolean result = rsuUpgradeContextService.hasCompleteRsuData(rsuIp); + + assertFalse(result); + verify(rsuRepository).findByIpv4Address(inetAddress); + } + + @Test + void testFindRsuByIp_ReturnsNullWhenMissing() throws UnknownHostException { + String rsuIp = "10.0.0.11"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(null); + + Rsu result = rsuUpgradeContextService.findRsuByIp(rsuIp); + + assertEquals(null, result); + assertFalse(rsuUpgradeContextService.hasCompleteRsuData(rsuIp)); + } + + @Test + void testFindRsuByIp_Success() throws UnknownHostException { + String rsuIp = "10.0.0.12"; + InetAddress inetAddress = InetAddress.getByName(rsuIp); + + Rsu rsu = new Rsu(); + rsu.setIpv4Address(inetAddress); + + when(rsuRepository.findByIpv4Address(inetAddress)).thenReturn(rsu); + + Rsu result = rsuUpgradeContextService.findRsuByIp(rsuIp); + + assertSame(rsu, result); + } + + @Test + void testFindRsuByIp_InvalidIpAddress() { + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> rsuUpgradeContextService.findRsuByIp("invalid-ip")); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Invalid RSU IP address")); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeServiceTest.java new file mode 100644 index 000000000..0ba859fb1 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/RsuUpgradeServiceTest.java @@ -0,0 +1,209 @@ +package us.dot.its.jpo.ode.api.services; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; +import us.dot.its.jpo.ode.api.models.postgres.dtos.FirmwareUpgradeCheckResponseDto; +import us.dot.its.jpo.ode.api.models.postgres.dtos.FirmwareUpgradeResultDto; +import us.dot.its.jpo.ode.api.models.postgres.tables.FirmwareImage; +import us.dot.its.jpo.ode.api.models.postgres.tables.FirmwareUpgradeRule; +import us.dot.its.jpo.ode.api.models.postgres.tables.Rsu; +import us.dot.its.jpo.ode.api.repositories.FirmwareUpgradeRuleRepository; +import us.dot.its.jpo.ode.api.repositories.RsuRepository; + +@ExtendWith(MockitoExtension.class) +class RsuUpgradeServiceTest { + + @Mock + private RsuUpgradeContextService rsuUpgradeContextService; + + @Mock + private FirmwareUpgradeRuleRepository firmwareUpgradeRuleRepository; + + @Mock + private RsuRepository rsuRepository; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private RsuUpgradeService rsuUpgradeService; + + @Test + void testCheckFirmwareUpgrade_Success() throws UnknownHostException { + String rsuIp = "10.0.0.10"; + + FirmwareImage currentImage = new FirmwareImage(); + currentImage.setId(1); + + FirmwareImage targetImage = new FirmwareImage(); + targetImage.setId(2); + targetImage.setName("RSU Firmware 2.0"); + targetImage.setVersion("2.0"); + + FirmwareUpgradeRule rule = new FirmwareUpgradeRule(); + rule.setFrom(currentImage); + rule.setTo(targetImage); + + Rsu rsu = new Rsu(); + rsu.setIpv4Address(InetAddress.getByName(rsuIp)); + rsu.setFirmwareVersion(currentImage); + + when(rsuUpgradeContextService.findRsuByIp(rsuIp)).thenReturn(rsu); + when(firmwareUpgradeRuleRepository.findFirstByFrom_Id(1)).thenReturn(Optional.of(rule)); + + FirmwareUpgradeCheckResponseDto result = rsuUpgradeService.checkFirmwareUpgrade(rsuIp); + + assertEquals(true, result.getUpgradeAvailable()); + assertEquals(2L, result.getUpgradeId()); + assertEquals("RSU Firmware 2.0", result.getUpgradeName()); + assertEquals("2.0", result.getUpgradeVersion()); + } + + @Test + void testStartFirmwareUpgradeForRsus_ReturnsPerRsuResultWhenRsuDataMissing() { + String successIp = "10.0.0.10"; + String missingIp = "10.0.0.11"; + + RsuUpgradeService serviceSpy = spy(rsuUpgradeService); + when(rsuUpgradeContextService.hasCompleteRsuData(successIp)).thenReturn(true); + when(rsuUpgradeContextService.hasCompleteRsuData(missingIp)).thenReturn(false); + doReturn(new RsuUpgradeService.UpgradeExecutionResult(Map.of("message", "started"), 201)) + .when(serviceSpy).executeUpgradeForRsu(successIp); + + Map result = serviceSpy + .startFirmwareUpgradeForRsus(List.of(successIp, missingIp)); + + assertEquals(201, result.get(successIp).getCode()); + assertEquals(Map.of("message", "started"), result.get(successIp).getData()); + assertEquals(404, result.get(missingIp).getCode()); + assertEquals("Provided RSU IP does not have complete RSU data: 10.0.0.11", result.get(missingIp).getData()); + } + + @Test + void testStartFirmwareUpgradeForRsus_ReturnsConflictWhenAlreadyUpToDate() { + String rsuIp = "10.0.0.12"; + + RsuUpgradeService serviceSpy = spy(rsuUpgradeService); + when(rsuUpgradeContextService.hasCompleteRsuData(rsuIp)).thenReturn(true); + doThrow(new RsuUpgradeService.FirmwareUpgradeUnavailableException("Requested RSU is already up to date")) + .when(serviceSpy).executeUpgradeForRsu(rsuIp); + + Map result = serviceSpy.startFirmwareUpgradeForRsus(List.of(rsuIp)); + + assertEquals(409, result.get(rsuIp).getCode()); + assertEquals("Requested RSU is already up to date", result.get(rsuIp).getData()); + } + + @Test + void testStartFirmwareUpgradeForRsus_ReturnsStatusCodePerRsuWhenFirmwareManagerUnsupported() { + String rsuIp = "10.0.0.15"; + + RsuUpgradeService serviceSpy = spy(rsuUpgradeService); + when(rsuUpgradeContextService.hasCompleteRsuData(rsuIp)).thenReturn(true); + doThrow(new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "The firmware manager is not supported")) + .when(serviceSpy).executeUpgradeForRsu(rsuIp); + + Map result = serviceSpy.startFirmwareUpgradeForRsus(List.of(rsuIp)); + + assertEquals(501, result.get(rsuIp).getCode()); + assertEquals("The firmware manager is not supported", result.get(rsuIp).getData()); + } + + @Test + void testMarkRsuForUpgrade_SuccessPostsJsonAndSavesTargetVersion() throws UnknownHostException { + String rsuIp = "10.0.0.13"; + String endpoint = "http://firmware-manager"; + + RestTemplate restTemplate = org.mockito.Mockito.mock(RestTemplate.class); + ReflectionTestUtils.setField(rsuUpgradeService, "firmwareManagerEndpoint", endpoint); + ReflectionTestUtils.setField(rsuUpgradeService, "restTemplate", restTemplate); + + FirmwareImage currentImage = new FirmwareImage(); + currentImage.setId(10); + + FirmwareImage targetImage = new FirmwareImage(); + targetImage.setId(11); + targetImage.setName("Target Firmware"); + targetImage.setVersion("11.0"); + + FirmwareUpgradeRule rule = new FirmwareUpgradeRule(); + rule.setFrom(currentImage); + rule.setTo(targetImage); + + Rsu rsu = new Rsu(); + rsu.setIpv4Address(InetAddress.getByName(rsuIp)); + rsu.setFirmwareVersion(currentImage); + + when(rsuUpgradeContextService.findRsuByIp(rsuIp)).thenReturn(rsu); + when(firmwareUpgradeRuleRepository.findFirstByFrom_Id(10)).thenReturn(Optional.of(rule)); + when(rsuRepository.save(any(Rsu.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(restTemplate.postForEntity(eq(endpoint + "/init_firmware_upgrade"), any(HttpEntity.class), eq(Map.class))) + .thenReturn(new ResponseEntity<>(Map.of("message", "started"), HttpStatus.OK)); + + RsuUpgradeService.UpgradeExecutionResult result = rsuUpgradeService.markRsuForUpgrade(rsuIp); + + assertEquals(200, result.statusCode()); + assertEquals(Map.of("message", "started"), result.body()); + assertEquals(targetImage, rsu.getTargetFirmwareVersion()); + + ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).postForEntity(eq(endpoint + "/init_firmware_upgrade"), entityCaptor.capture(), + eq(Map.class)); + + HttpEntity requestEntity = entityCaptor.getValue(); + assertNotNull(requestEntity); + assertEquals(MediaType.APPLICATION_JSON, requestEntity.getHeaders().getContentType()); + assertEquals(Map.of("rsu_ip", rsuIp), requestEntity.getBody()); + } + + @Test + void testMarkRsuForUpgrade_ThrowsConflictWhenAlreadyUpToDate() throws UnknownHostException { + String rsuIp = "10.0.0.14"; + + ReflectionTestUtils.setField(rsuUpgradeService, "firmwareManagerEndpoint", "http://firmware-manager"); + + FirmwareImage currentImage = new FirmwareImage(); + currentImage.setId(20); + + Rsu rsu = new Rsu(); + rsu.setIpv4Address(InetAddress.getByName(rsuIp)); + rsu.setFirmwareVersion(currentImage); + + when(rsuUpgradeContextService.findRsuByIp(rsuIp)).thenReturn(rsu); + when(firmwareUpgradeRuleRepository.findFirstByFrom_Id(20)).thenReturn(Optional.empty()); + + RsuUpgradeService.FirmwareUpgradeUnavailableException exception = assertThrows( + RsuUpgradeService.FirmwareUpgradeUnavailableException.class, + () -> rsuUpgradeService.markRsuForUpgrade(rsuIp)); + + assertTrue(exception.getMessage().contains("already up to date")); + } +} diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/ScmsHealthServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/ScmsHealthServiceTest.java new file mode 100644 index 000000000..16fe9cfd3 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/ScmsHealthServiceTest.java @@ -0,0 +1,400 @@ +package us.dot.its.jpo.ode.api.services; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import us.dot.its.jpo.ode.api.TestcontainersConfiguration; +import us.dot.its.jpo.ode.api.fixtures.TestFixtures; +import us.dot.its.jpo.ode.api.models.postgres.tables.*; +import us.dot.its.jpo.ode.api.repositories.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import us.dot.its.jpo.ode.api.models.postgres.projections.ScmsHealthRsuProjection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("integration-test") +@Import(TestcontainersConfiguration.class) +@Transactional +class ScmsHealthServiceTest { + + @Autowired + private ScmsHealthService scmsHealthService; + + @Autowired + private ScmsHealthRepository scmsHealthRepository; + + @Autowired + private RsuRepository rsuRepository; + + @Autowired + private OrganizationRepository organizationRepository; + + @Autowired + private RsuOrganizationRepository rsuOrganizationRepository; + + @Autowired + private ManufacturerRepository manufacturerRepository; + + @Autowired + private RsuModelRepository rsuModelRepository; + + @Autowired + private RsuCredentialRepository rsuCredentialRepository; + + @Autowired + private SnmpCredentialRepository snmpCredentialRepository; + + @Autowired + private SnmpProtocolRepository snmpProtocolRepository; + + private final TestFixtures fixtures = new TestFixtures(); + + @BeforeEach + void setUp() { + // Delete in reverse FK-dependency order: child tables before parents. + // organizations must be last because rsu_credentials and snmp_credentials + // have NOT NULL FKs to it. + scmsHealthRepository.deleteAll(); + rsuOrganizationRepository.deleteAll(); + rsuRepository.deleteAll(); + rsuCredentialRepository.deleteAll(); + snmpCredentialRepository.deleteAll(); + snmpProtocolRepository.deleteAll(); + rsuModelRepository.deleteAll(); + manufacturerRepository.deleteAll(); + organizationRepository.deleteAll(); + } + + @Test + @DisplayName("Returns latest health status for each RSU in organization") + void testGetScmsStatuses_ReturnsLatestForEachRsuInOrganization() throws Exception { + // Arrange + Organization org1 = organizationRepository.save(fixtures.createOrg("Org1")); + Organization org2 = organizationRepository.save(fixtures.createOrg("Org2")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCred = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org1)); + RsuCredential rsuCred = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org1)); + + Rsu rsu1 = rsuRepository.save(fixtures.createRsu("10.0.0.1", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu1, org1)); + Rsu rsu2 = rsuRepository.save(fixtures.createRsu("10.0.0.2", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu2, org1)); + Rsu rsu3 = rsuRepository.save(fixtures.createRsu("10.0.0.3", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu3, org2)); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + Instant oneHourEarlier = now.minus(1, ChronoUnit.HOURS); + + // SCMS Health records + saveScmsHealth(rsu1, oneHourEarlier, true); + ScmsHealth rsu1Latest = saveScmsHealth(rsu1, now, false); + + // Single health record for RSU 2 + ScmsHealth rsu2Latest = saveScmsHealth(rsu2, now, true); + + // Health record for RSU 3 (Org 2) + saveScmsHealth(rsu3, now, true); + + // Act + List results = scmsHealthService.getScmsStatuses("Org1"); + + // Assert + assertEquals(2, results.size(), "Should return 2 records for Org1"); + + ScmsHealthRsuProjection result1 = results.stream() + .filter(res -> res.getIpv4Address().getHostAddress().equals("10.0.0.1")) + .findFirst().orElseThrow(); + assertEquals(rsu1Latest.getHealth(), result1.getHealth()); + + ScmsHealthRsuProjection result2 = results.stream() + .filter(res -> res.getIpv4Address().getHostAddress().equals("10.0.0.2")) + .findFirst().orElseThrow(); + assertEquals(rsu2Latest.getHealth(), result2.getHealth()); + } + + @Test + @DisplayName("Returns empty list when organization has no RSUs") + void testGetScmsStatuses_ReturnsEmpty_WhenOrganizationHasNoRsus() { + // Arrange + organizationRepository.save(fixtures.createOrg("EmptyOrg")); + + // Act + List results = scmsHealthService.getScmsStatuses("EmptyOrg"); + + // Assert + assertTrue(results.isEmpty(), "Should return an empty list for an organization with no RSUs"); + } + + @Test + @DisplayName("When an organization has RSUs but no health records, the health fields are null") + void testGetScmsStatuses_ReturnsNullHealth_WhenOrganizationHasRsusButNoHealthRecords() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("NoHealthOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCred = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCred = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Rsu rsu = rsuRepository.save(fixtures.createRsu("10.0.0.10", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + // Act + List results = scmsHealthService.getScmsStatuses("NoHealthOrg"); + + // Assert + assertEquals(1, results.size(), "Should return 1 entry even when RSU has no health records"); + assertNull(results.getFirst().getHealth(), "Health should be null when the RSU has no health records"); + assertNull(results.getFirst().getExpiration(), "Expiration should be null when the RSU has no health records"); + } + + @Test + @DisplayName("Returns empty list when organization does not exist") + void testGetScmsStatuses_ReturnsEmpty_WhenOrganizationDoesNotExist() { + // Act + List results = scmsHealthService.getScmsStatuses("NonExistentOrg"); + + // Assert + assertTrue(results.isEmpty(), "Should return an empty list for a non-existent organization"); + } + + @Test + @DisplayName("Given two records with the same timestamp, only one row is returned") + void testGetScmsStatuses_ReturnsExactlyOneRowPerRsu_WhenMultipleRecordsHaveSameTimestamp() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("SameTimestampOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCred = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCred = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Rsu rsu = rsuRepository.save(fixtures.createRsu("10.0.0.20", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + Instant sameTime = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Create multiple health records with identical timestamps. + // The query must return exactly one row per RSU (matching legacy ROW_NUMBER behavior). + saveScmsHealth(rsu, sameTime, true); + saveScmsHealth(rsu, sameTime, false); + + // Act + List results = scmsHealthService.getScmsStatuses("SameTimestampOrg"); + + // Assert + assertEquals(1, results.size(), + "Should return exactly one record per RSU even when multiple records have the same timestamp"); + + ScmsHealthRsuProjection result = results.getFirst(); + assertNotNull(result.getHealth(), "Health should not be null"); + } + + @Test + @DisplayName("Given many tied timestamps, exactly one row is returned") + void testGetScmsStatuses_ReturnsExactlyOneRowPerRsu_WhenMoreThanTwoRecordsHaveSameTimestamp() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("ManyTiesOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCredential = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCredential = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Rsu rsu = rsuRepository.save(fixtures.createRsu("10.0.0.30", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu, org)); + + Instant sameTime = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Create 5 health records with identical timestamps + saveScmsHealth(rsu, sameTime, true); + saveScmsHealth(rsu, sameTime, false); + saveScmsHealth(rsu, sameTime, true); + saveScmsHealth(rsu, sameTime, false); + saveScmsHealth(rsu, sameTime, true); + + // Act + List results = scmsHealthService.getScmsStatuses("ManyTiesOrg"); + + // Assert + assertEquals(1, results.size(), + "Should return exactly one record per RSU even with many timestamp ties"); + } + + @Test + @DisplayName("Results ordered by IPv4 address") + void testGetScmsStatuses_ResultsOrderedByIpv4Address() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("OrderedOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCredential = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCredential = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Create RSUs in non-sorted order + Rsu rsu3 = rsuRepository.save(fixtures.createRsu("10.0.0.103", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu3, org)); + Rsu rsu1 = rsuRepository.save(fixtures.createRsu("10.0.0.101", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu1, org)); + Rsu rsu2 = rsuRepository.save(fixtures.createRsu("10.0.0.102", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu2, org)); + + saveScmsHealth(rsu3, now, true); + saveScmsHealth(rsu1, now, true); + saveScmsHealth(rsu2, now, true); + + // Act + List results = scmsHealthService.getScmsStatuses("OrderedOrg"); + + // Assert - Results should be sorted by IPv4 address + assertEquals(3, results.size()); + assertEquals("10.0.0.101", results.get(0).getIpv4Address().getHostAddress(), + "First result should be 10.0.0.101"); + assertEquals("10.0.0.102", results.get(1).getIpv4Address().getHostAddress(), + "Second result should be 10.0.0.102"); + assertEquals("10.0.0.103", results.get(2).getIpv4Address().getHostAddress(), + "Third result should be 10.0.0.103"); + } + + @Test + @DisplayName("RSU in multiple organizations appears in each organization's query") + void testGetScmsStatuses_RsuInMultipleOrganizations_AppearsInEachOrgQuery() throws Exception { + // Arrange - An RSU can belong to multiple organizations + Organization org1 = organizationRepository.save(fixtures.createOrg("MultiOrg1")); + Organization org2 = organizationRepository.save(fixtures.createOrg("MultiOrg2")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCredential = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org1)); + RsuCredential rsuCredential = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org1)); + + // Create RSU and associate with both organizations + Rsu sharedRsu = rsuRepository.save(fixtures.createRsu("10.0.0.80", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(sharedRsu, org1)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(sharedRsu, org2)); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + ScmsHealth healthRecord = saveScmsHealth(sharedRsu, now, true); + + // Act + List resultsOrg1 = scmsHealthService.getScmsStatuses("MultiOrg1"); + List resultsOrg2 = scmsHealthService.getScmsStatuses("MultiOrg2"); + + // Assert - RSU should appear in both organization queries + assertEquals(1, resultsOrg1.size(), "RSU should appear in MultiOrg1 results"); + assertEquals(1, resultsOrg2.size(), "RSU should appear in MultiOrg2 results"); + assertEquals(healthRecord.getHealth(), resultsOrg1.getFirst().getHealth()); + assertEquals(healthRecord.getHealth(), resultsOrg2.getFirst().getHealth()); + } + + @Test + @DisplayName("RSUs without health records are included") + void testGetScmsStatuses_MixedRsusWithAndWithoutHealthRecords() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("MixedOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCred = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCred = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // RSU with health records + Rsu rsuWithHealth = rsuRepository.save(fixtures.createRsu("10.0.0.60", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsuWithHealth, org)); + ScmsHealth healthRecord = saveScmsHealth(rsuWithHealth, now, true); + + // RSU without health records + Rsu rsuWithoutHealth = rsuRepository.save(fixtures.createRsu("10.0.0.61", model, rsuCred, snmpCred, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsuWithoutHealth, org)); + + // Act + List results = scmsHealthService.getScmsStatuses("MixedOrg"); + + // Assert - Both RSUs should be returned, sorted by IP + assertEquals(2, results.size(), "Should return both RSUs"); + + // First RSU (10.0.0.60) has health record + assertEquals("10.0.0.60", results.getFirst().getIpv4Address().getHostAddress()); + assertNotNull(results.get(0).getHealth(), "First RSU should have health record"); + assertEquals(healthRecord.getHealth(), results.get(0).getHealth()); + + // Second RSU (10.0.0.61) has no health record + assertEquals("10.0.0.61", results.get(1).getIpv4Address().getHostAddress()); + assertNull(results.get(1).getHealth(), "Second RSU should have null health"); + } + + @Test + @DisplayName("Query returns deterministic results on repeated calls") + void testGetScmsStatuses_DeterministicResults_MultipleCallsReturnSameOrder() throws Exception { + // Arrange + Organization org = organizationRepository.save(fixtures.createOrg("DeterministicOrg")); + + Manufacturer manufacturer = manufacturerRepository.save(fixtures.createRandomManufacturer()); + RsuModel model = rsuModelRepository.save(fixtures.createRandomRsuModel(manufacturer)); + SnmpProtocol protocol = snmpProtocolRepository.save(fixtures.createRandomSnmpProtocol()); + SnmpCredential snmpCredential = snmpCredentialRepository.save(fixtures.createRandomSnmpCredential(org)); + RsuCredential rsuCredential = rsuCredentialRepository.save(fixtures.createRandomRsuCredential(org)); + + Instant sameTime = Instant.now().truncatedTo(ChronoUnit.MICROS); + + Rsu rsu1 = rsuRepository.save(fixtures.createRsu("10.0.0.70", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu1, org)); + Rsu rsu2 = rsuRepository.save(fixtures.createRsu("10.0.0.71", model, rsuCredential, snmpCredential, protocol)); + rsuOrganizationRepository.save(fixtures.createRsuOrganization(rsu2, org)); + + // Create multiple records with same timestamp for each RSU + saveScmsHealth(rsu1, sameTime, true); + saveScmsHealth(rsu1, sameTime, false); + saveScmsHealth(rsu2, sameTime, false); + saveScmsHealth(rsu2, sameTime, true); + + // Act - Call multiple times + List results1 = scmsHealthService.getScmsStatuses("DeterministicOrg"); + List results2 = scmsHealthService.getScmsStatuses("DeterministicOrg"); + List results3 = scmsHealthService.getScmsStatuses("DeterministicOrg"); + + // Assert - All calls should return identical results + assertEquals(2, results1.size()); + assertEquals(2, results2.size()); + assertEquals(2, results3.size()); + + // Verify same health values are returned each time + assertEquals(results1.get(0).getHealth(), results2.get(0).getHealth()); + assertEquals(results1.get(0).getHealth(), results3.get(0).getHealth()); + assertEquals(results1.get(1).getHealth(), results2.get(1).getHealth()); + assertEquals(results1.get(1).getHealth(), results3.get(1).getHealth()); + } + + + private ScmsHealth saveScmsHealth(Rsu rsu, Instant timestamp, boolean health) { + ScmsHealth scmsHealth = new ScmsHealth(); + scmsHealth.setRsu(rsu); + scmsHealth.setTimestamp(timestamp); + scmsHealth.setHealth(health); + scmsHealth.setExpiration(timestamp.plus(30, ChronoUnit.DAYS)); + return scmsHealthRepository.save(scmsHealth); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/SnmpCredentialManagementServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/SnmpCredentialManagementServiceTest.java new file mode 100644 index 000000000..e75f44be0 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/SnmpCredentialManagementServiceTest.java @@ -0,0 +1,325 @@ +package us.dot.its.jpo.ode.api.services; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import us.dot.its.jpo.ode.api.controllers.credentials.SnmpCredentialController; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.SnmpCredential; +import us.dot.its.jpo.ode.api.repositories.OrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.SnmpCredentialRepository; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SnmpCredentialManagementServiceTest { + + @Mock + SnmpCredentialRepository mockSnmpCredentialRepository; + + @Mock + OrganizationRepository mockOrganizationRepository; + + @Mock + PermissionService mockPermissionService; + + @InjectMocks + SnmpCredentialManagementService snmpCredentialManagementService; + + @Test + void testCreate_Success() throws SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException, EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int ownerOrganizationId = 1; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + + Organization mockOrganization = mock(Organization.class); + when(mockOrganization.getId()).thenReturn(ownerOrganizationId); + when(mockOrganizationRepository.findByName(organization)).thenReturn(Optional.of(mockOrganization)); + + SnmpCredential expectedSnmpCredential = new SnmpCredential(); + expectedSnmpCredential.setNickname(nickname); + expectedSnmpCredential.setUsername(username); + expectedSnmpCredential.setPassword(password); + expectedSnmpCredential.setOwnerOrganization(mockOrganization); + when(mockSnmpCredentialRepository.save(any())).thenReturn(expectedSnmpCredential); + + // Act + SnmpCredential snmpCredential = snmpCredentialManagementService.create(request); + + // Assert + assertEquals(nickname, snmpCredential.getNickname()); + assertEquals(username, snmpCredential.getUsername()); + assertEquals(password, snmpCredential.getPassword()); + assertEquals(ownerOrganizationId, snmpCredential.getOwnerOrganization().getId()); + verify(mockSnmpCredentialRepository).save(any()); + } + + @Test + void testCreate_Failure_AlreadyExists() { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + + when(mockSnmpCredentialRepository.existsByNickname(nickname)).thenReturn(true); + + // Act & Assert + assertThrows(SnmpCredentialManagementService.SnmpCredentialAlreadyExistsException.class, () -> snmpCredentialManagementService.create(request)); + } + + @Test + void testCreate_Failure_OrganizationNotFound() { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + SnmpCredentialController.SnmpCredentialCreateRequest request = new SnmpCredentialController.SnmpCredentialCreateRequest(nickname, username, password, organization); + + when(mockOrganizationRepository.findByName(organization)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialManagementService.create(request)); + } + + @Test + void testGetByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String organization = "organization"; + int organizationId = 1; + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + + SnmpCredential expectedSnmpCredential = new SnmpCredential(); + expectedSnmpCredential.setOwnerOrganization(mockOrganization); + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(expectedSnmpCredential)); + + // Act + SnmpCredential snmpCredential = snmpCredentialManagementService.getByNickname(nickname); + + // Assert + assertNotNull(snmpCredential); + assertEquals(expectedSnmpCredential, snmpCredential); + verify(mockSnmpCredentialRepository).findByNickname(nickname); + } + + @Test + void testGetByNickname_Failure_NotFound() { + // Arrange + String nickname = "nickname"; + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialManagementService.getByNickname(nickname)); + } + + @Test + void testUpdate_ChangePassword_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newPassword = "mynewpassword"; + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setPassword(newPassword); + + SnmpCredential existingCredential = new SnmpCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + SnmpCredential expectedCredential = new SnmpCredential(); + expectedCredential.setNickname(nickname); + expectedCredential.setUsername(username); + expectedCredential.setPassword(newPassword); + expectedCredential.setOwnerOrganization(mockOrganization); + + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockSnmpCredentialRepository.save(any())).thenReturn(expectedCredential); + + // Act + SnmpCredential updatedCredential = snmpCredentialManagementService.update(patch); + + // Assert + assertNotNull(updatedCredential); + assertEquals(nickname, updatedCredential.getNickname()); + assertEquals(username, updatedCredential.getUsername()); + assertEquals(newPassword, updatedCredential.getPassword()); + assertEquals(organizationId, updatedCredential.getOwnerOrganization().getId()); + verify(mockSnmpCredentialRepository).findByNickname(nickname); + verify(mockSnmpCredentialRepository).save(any()); + } + + @Test + void testUpdate_ChangeOrganization_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String oldOrganization = "oldOrganization"; + int oldOrganizationId = 1; + String newOrganization = "newOrganization"; + int newOrganizationId = 2; + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setOrganization(newOrganization); + + SnmpCredential existingCredential = new SnmpCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOldOrganization = mock(Organization.class); + lenient().when(mockOldOrganization.getId()).thenReturn(oldOrganizationId); + lenient().when(mockOldOrganization.getName()).thenReturn(oldOrganization); + existingCredential.setOwnerOrganization(mockOldOrganization); + + Organization mockNewOrganization = mock(Organization.class); + lenient().when(mockNewOrganization.getId()).thenReturn(newOrganizationId); + lenient().when(mockNewOrganization.getName()).thenReturn(newOrganization); + + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockSnmpCredentialRepository.save(any())).thenReturn(existingCredential); + + when(mockOrganizationRepository.findByName(newOrganization)).thenReturn(Optional.of(mockNewOrganization)); + + // Act + SnmpCredential updatedCredential = snmpCredentialManagementService.update(patch); + + // Assert + assertNotNull(updatedCredential); + assertEquals(nickname, updatedCredential.getNickname()); + assertEquals(username, updatedCredential.getUsername()); + assertEquals(password, updatedCredential.getPassword()); + assertEquals(newOrganizationId, updatedCredential.getOwnerOrganization().getId()); + verify(mockSnmpCredentialRepository).findByNickname(nickname); + } + + @Test + void testUpdate_ChangeUsername_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newUsername = "newUsername"; + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setUsername(newUsername); + + SnmpCredential existingCredential = new SnmpCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + SnmpCredential expectedCredential = new SnmpCredential(); + expectedCredential.setNickname(nickname); + expectedCredential.setUsername(newUsername); + expectedCredential.setPassword(password); + expectedCredential.setOwnerOrganization(mockOrganization); + + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockSnmpCredentialRepository.save(any())).thenReturn(expectedCredential); + + // Act + SnmpCredential snmpCredential = snmpCredentialManagementService.update(patch); + + // Assert + assertNotNull(snmpCredential); + assertEquals(nickname, snmpCredential.getNickname()); + assertEquals(newUsername, snmpCredential.getUsername()); + assertEquals(password, snmpCredential.getPassword()); + assertEquals(organizationId, snmpCredential.getOwnerOrganization().getId()); + verify(mockSnmpCredentialRepository).findByNickname(nickname); + verify(mockSnmpCredentialRepository).save(any()); + } + + @Test + void testUpdate_ChangeOrganization_Failure_OrganizationNotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String username = "username"; + String password = "password"; + String organization = "organization"; + int organizationId = 1; + String newOrganization = "newOrganization"; + SnmpCredentialController.SnmpCredentialPatch patch = new SnmpCredentialController.SnmpCredentialPatch(nickname); + patch.setOrganization(newOrganization); + + SnmpCredential existingCredential = new SnmpCredential(); + existingCredential.setNickname(nickname); + existingCredential.setUsername(username); + existingCredential.setPassword(password); + + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + when(mockOrganizationRepository.findByName(newOrganization)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialManagementService.update(patch)); + } + + @Test + void testDeleteByNickname_Success() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + String organization = "organization"; + int organizationId = 1; + + SnmpCredential existingCredential = new SnmpCredential(); + Organization mockOrganization = mock(Organization.class); + lenient().when(mockOrganization.getId()).thenReturn(organizationId); + lenient().when(mockOrganization.getName()).thenReturn(organization); + existingCredential.setOwnerOrganization(mockOrganization); + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.of(existingCredential)); + + // Act + snmpCredentialManagementService.deleteByNickname(nickname); + + // Assert + verify(mockSnmpCredentialRepository).delete(existingCredential); + } + + @Test + void testDeleteByNickname_Failure_CredentialNotFound() throws EntityNotFoundException { + // Arrange + String nickname = "nickname"; + when(mockSnmpCredentialRepository.findByNickname(nickname)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(EntityNotFoundException.class, () -> snmpCredentialManagementService.deleteByNickname(nickname)); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/UserManagementServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/UserManagementServiceTest.java new file mode 100644 index 000000000..5ccf4f203 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/UserManagementServiceTest.java @@ -0,0 +1,1085 @@ +package us.dot.its.jpo.ode.api.services; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.UserRepresentation; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.context.ActiveProfiles; + +import jakarta.persistence.EntityNotFoundException; +import us.dot.its.jpo.ode.api.keycloak.config.KeycloakAdminConfig; +import us.dot.its.jpo.ode.api.mappers.UserMapper; +import us.dot.its.jpo.ode.api.mappers.UserPatchMapper; +import us.dot.its.jpo.ode.api.models.UserRole; +import us.dot.its.jpo.ode.api.models.keycloak.CvManagerAuthToken; +import us.dot.its.jpo.ode.api.models.postgres.tables.Organization; +import us.dot.its.jpo.ode.api.models.postgres.tables.Role; +import us.dot.its.jpo.ode.api.models.postgres.tables.User; +import us.dot.its.jpo.ode.api.models.postgres.tables.UserOrganization; +import us.dot.its.jpo.ode.api.models.users.ModifyUserAllowedSelections; +import us.dot.its.jpo.ode.api.models.users.UserDto; +import us.dot.its.jpo.ode.api.models.users.UserOrganizationDto; +import us.dot.its.jpo.ode.api.models.users.UserPatch; +import us.dot.its.jpo.ode.api.repositories.OrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.RoleRepository; +import us.dot.its.jpo.ode.api.repositories.UserOrganizationRepository; +import us.dot.its.jpo.ode.api.repositories.UserRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import javax.ws.rs.core.Response; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ActiveProfiles("integration-test") +@ExtendWith(MockitoExtension.class) +class UserManagementServiceTest { + + @Mock + private RoleRepository roleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserOrganizationRepository userOrganizationRepository; + + @Mock + private OrganizationRepository organizationRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private UserPatchMapper userPatchMapper; + + @Mock + private CvManagerAuthToken authToken; + + @Mock + private KeycloakAdminConfig keycloakAdminConfig; + + @InjectMocks + private UserManagementService userManagementService; + + private User testUser; + private UserDto testUserDto; + private Organization testOrganization; + private Role testRole; + + @BeforeEach + void setUp() { + // Set up test user + testUser = new User(); + testUser.setId(1); + testUser.setEmail("test@example.com"); + testUser.setFirstName("Test"); + testUser.setLastName("User"); + testUser.setKeycloakId(UUID.randomUUID()); + testUser.setCreatedTimestamp(System.currentTimeMillis()); + testUser.setSuperUser(false); + + // Set up test user DTO + testUserDto = new UserDto("test@example.com", "Test", "User", false, List.of()); + + // Set up test organization + testOrganization = new Organization(); + testOrganization.setId(1); + testOrganization.setName("TestOrg"); + + // Set up test role + testRole = new Role(); + testRole.setId(1); + testRole.setName("admin"); + } + + // ==================== getUser Tests ==================== + + @Test + void testGetUser_Success() { + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.getUser("test@example.com"); + + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + verify(userRepository).findByEmail("test@example.com"); + verify(userMapper).toDto(testUser); + } + + @Test + void testGetUser_NotFound() { + when(userRepository.findByEmail("nonexistent@example.com")).thenThrow(EntityNotFoundException.class); + + assertThrows(EntityNotFoundException.class, + () -> userManagementService.getUser("nonexistent@example.com")); + + verify(userRepository).findByEmail("nonexistent@example.com"); + verify(userMapper, never()).toDto(any()); + } + + // ==================== getUsers Tests ==================== + + @Test + void testGetUsers_WithOrganization() { + Pageable pageable = PageRequest.of(0, 10); + List users = List.of(testUser); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userRepository.findAllByOrganization("TestOrg", "", pageable)).thenReturn(userPage); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + Page result = userManagementService.getUsers("TestOrg", "", pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals("test@example.com", result.getContent().get(0).getEmail()); + verify(userRepository).findAllByOrganization("TestOrg", "", pageable); + } + + @Test + void testGetUsers_WithSearch() { + Pageable pageable = PageRequest.of(0, 10); + List users = List.of(testUser); + Page userPage = new PageImpl<>(users, pageable, 1); + + when(userRepository.findAllByOrganization("TestOrg", "test", pageable)).thenReturn(userPage); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + Page result = userManagementService.getUsers("TestOrg", "test", pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + verify(userRepository).findAllByOrganization("TestOrg", "test", pageable); + } + + @Test + void testGetUsers_EmptyResult() { + Pageable pageable = PageRequest.of(0, 10); + Page userPage = new PageImpl<>(List.of(), pageable, 0); + + when(userRepository.findAllByOrganization("TestOrg", "", pageable)).thenReturn(userPage); + + Page result = userManagementService.getUsers("TestOrg", "", pageable); + + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + verify(userRepository).findAllByOrganization("TestOrg", "", pageable); + } + + // ==================== getAllowedSelections Tests ==================== + + @Test + void testGetAllowedSelections_Success() { + List roles = List.of("admin", "operator", "user"); + List organizations = List.of("TestOrg", "AnotherOrg"); + + when(roleRepository.findAllRoleNames()).thenReturn(roles); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(organizations); + + ModifyUserAllowedSelections result = userManagementService.getAllowedSelections(authToken); + + assertNotNull(result); + assertEquals(roles, result.getRoles()); + assertEquals(organizations, result.getOrganizations()); + verify(roleRepository).findAllRoleNames(); + verify(authToken).getQualifiedOrgList(UserRole.ADMIN); + } + + @Test + void testGetAllowedSelections_EmptyLists() { + when(roleRepository.findAllRoleNames()).thenReturn(List.of()); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of()); + + ModifyUserAllowedSelections result = userManagementService.getAllowedSelections(authToken); + + assertNotNull(result); + assertTrue(result.getRoles().isEmpty()); + assertTrue(result.getOrganizations().isEmpty()); + } + + // ==================== modifyUser Tests ==================== + + @Test + void testModifyUser_UpdateBasicFields() { + UserPatch patch = new UserPatch(); + patch.setFirstName("Updated"); + patch.setLastName("Name"); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userRepository.save(testUser)).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.modifyUser("test@example.com", patch, authToken); + + assertNotNull(result); + verify(userRepository).findByEmail("test@example.com"); + verify(userPatchMapper).updateUserFromPatch(patch, testUser); + verify(userRepository).save(testUser); + verify(userMapper).toDto(testUser); + } + + @Test + void testModifyUser_UserNotFound() { + UserPatch patch = new UserPatch(); + + when(userRepository.findByEmail("nonexistent@example.com")).thenThrow(EntityNotFoundException.class); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + + assertThrows(EntityNotFoundException.class, + () -> userManagementService.modifyUser("nonexistent@example.com", patch, authToken)); + + verify(userRepository).findByEmail("nonexistent@example.com"); + verify(userRepository, never()).save(any()); + } + + @Test + void testModifyUser_AddOrganization_Success() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToAdd = new UserOrganizationDto(); + orgToAdd.setOrganization("TestOrg"); + orgToAdd.setRole("admin"); + patch.setOrganizationsToAdd(List.of(orgToAdd)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userRepository.existsByEmailAndOrganizations("test@example.com", List.of("TestOrg"))).thenReturn(false); + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("admin")).thenReturn(Optional.of(testRole)); + when(userRepository.save(testUser)).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.modifyUser("test@example.com", patch, authToken); + + assertNotNull(result); + verify(organizationRepository).findByName("TestOrg"); + verify(roleRepository).findByNameIgnoreCase("admin"); + verify(userOrganizationRepository).save(any(UserOrganization.class)); + } + + @Test + void testModifyUser_AddOrganization_Unauthorized() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToAdd = new UserOrganizationDto(); + orgToAdd.setOrganization("UnauthorizedOrg"); + orgToAdd.setRole("admin"); + patch.setOrganizationsToAdd(List.of(orgToAdd)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + + AccessDeniedException exception = assertThrows(AccessDeniedException.class, + () -> userManagementService.modifyUser("test@example.com", patch, authToken)); + + assertEquals("User does not have permission to add User to organization(s): UnauthorizedOrg", + exception.getMessage()); + verify(userOrganizationRepository, never()).save(any()); + } + + @Test + void testModifyUser_AddOrganization_AlreadyExists() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToAdd = new UserOrganizationDto(); + orgToAdd.setOrganization("TestOrg"); + orgToAdd.setRole("admin"); + patch.setOrganizationsToAdd(List.of(orgToAdd)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userRepository.existsByEmailAndOrganizations("test@example.com", List.of("TestOrg"))).thenReturn(true); + when(userRepository.save(testUser)).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.modifyUser("test@example.com", patch, authToken); + + assertNotNull(result); + // Should not create a new association since it already exists + verify(userOrganizationRepository, never()).save(any()); + } + + @Test + void testModifyUser_AddOrganization_OrganizationNotFound() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToAdd = new UserOrganizationDto(); + orgToAdd.setOrganization("NonexistentOrg"); + orgToAdd.setRole("admin"); + patch.setOrganizationsToAdd(List.of(orgToAdd)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("NonexistentOrg")); + when(userRepository.existsByEmailAndOrganizations("test@example.com", List.of("NonexistentOrg"))) + .thenReturn(false); + when(organizationRepository.findByName("NonexistentOrg")).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> userManagementService.modifyUser("test@example.com", patch, authToken)); + + assertEquals("Organization not found: NonexistentOrg", exception.getMessage()); + verify(userOrganizationRepository, never()).save(any()); + } + + @Test + void testModifyUser_AddOrganization_RoleNotFound() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToAdd = new UserOrganizationDto(); + orgToAdd.setOrganization("TestOrg"); + orgToAdd.setRole("nonexistent_role"); + patch.setOrganizationsToAdd(List.of(orgToAdd)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userRepository.existsByEmailAndOrganizations("test@example.com", List.of("TestOrg"))).thenReturn(false); + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("nonexistent_role")).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> userManagementService.modifyUser("test@example.com", patch, authToken)); + + assertEquals("Role not found: nonexistent_role", exception.getMessage()); + verify(userOrganizationRepository, never()).save(any()); + } + + @Test + void testModifyUser_RemoveOrganization_Success() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToRemove = new UserOrganizationDto(); + orgToRemove.setOrganization("TestOrg"); + orgToRemove.setRole("admin"); + patch.setOrganizationsToRemove(List.of(orgToRemove)); + + UserOrganization userOrg = new UserOrganization(); + userOrg.setUser(testUser); + userOrg.setOrganization(testOrganization); + userOrg.setRole(testRole); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userOrganizationRepository.findByUserAndOrganization_Name(testUser, "TestOrg")) + .thenReturn(Optional.of(userOrg)); + when(userRepository.save(testUser)).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.modifyUser("test@example.com", patch, authToken); + + assertNotNull(result); + verify(userOrganizationRepository).delete(userOrg); + } + + @Test + void testModifyUser_RemoveOrganization_Unauthorized() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToRemove = new UserOrganizationDto(); + orgToRemove.setOrganization("UnauthorizedOrg"); + orgToRemove.setRole("admin"); + patch.setOrganizationsToRemove(List.of(orgToRemove)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + + AccessDeniedException exception = assertThrows(AccessDeniedException.class, + () -> userManagementService.modifyUser("test@example.com", patch, authToken)); + + assertEquals("User does not have permission to remove User from organization(s): UnauthorizedOrg", + exception.getMessage()); + verify(userOrganizationRepository, never()).delete(any()); + } + + @Test + void testModifyUser_RemoveOrganization_NotFound() { + UserPatch patch = new UserPatch(); + UserOrganizationDto orgToRemove = new UserOrganizationDto(); + orgToRemove.setOrganization("TestOrg"); + orgToRemove.setRole("admin"); + patch.setOrganizationsToRemove(List.of(orgToRemove)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + when(authToken.getQualifiedOrgList(UserRole.ADMIN)).thenReturn(List.of("TestOrg")); + when(userOrganizationRepository.findByUserAndOrganization_Name(testUser, "TestOrg")) + .thenReturn(Optional.empty()); + when(userRepository.save(testUser)).thenReturn(testUser); + when(userMapper.toDto(testUser)).thenReturn(testUserDto); + + UserDto result = userManagementService.modifyUser("test@example.com", patch, authToken); + + assertNotNull(result); + // Should not throw exception, just skip deletion + verify(userOrganizationRepository, never()).delete(any()); + } + + // ==================== deleteUserByEmail Tests ==================== + + @Test + void testDeleteUserByEmail_Success() { + when(userRepository.findByEmail("test@example.com")).thenReturn(testUser); + + userManagementService.deleteUserByEmail("test@example.com"); + + verify(userRepository).findByEmail("test@example.com"); + verify(userOrganizationRepository).removeUserOrganizationByEmail("test@example.com"); + verify(userRepository).delete(testUser); + } + + @Test + void testDeleteUserByEmail_UserNotFound() { + when(userRepository.findByEmail("nonexistent@example.com")).thenThrow(EntityNotFoundException.class); + + assertThrows(EntityNotFoundException.class, + () -> userManagementService.deleteUserByEmail("nonexistent@example.com")); + + verify(userRepository, never()).delete(any()); + verify(userOrganizationRepository, never()).removeUserOrganizationByEmail(any()); + } + + // ==================== deleteMultipleUsersByEmail Tests ==================== + + @Test + void testDeleteMultipleUsersByEmail_Success() { + List emails = List.of("test1@example.com", "test2@example.com"); + User user1 = new User(); + user1.setEmail("test1@example.com"); + User user2 = new User(); + user2.setEmail("test2@example.com"); + List users = List.of(user1, user2); + + when(userRepository.findByEmailIn(emails)).thenReturn(users); + + userManagementService.deleteMultipleUsersByEmail(emails); + + verify(userRepository).findByEmailIn(emails); + verify(userOrganizationRepository).removeMultipleUserOrganizationsByEmail(emails); + verify(userRepository).deleteAll(users); + } + + @Test + void testDeleteMultipleUsersByEmail_SomeNotFound() { + List emails = List.of("test1@example.com", "nonexistent@example.com"); + User user1 = new User(); + user1.setEmail("test1@example.com"); + List users = List.of(user1); + + when(userRepository.findByEmailIn(emails)).thenReturn(users); + + EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, + () -> userManagementService.deleteMultipleUsersByEmail(emails)); + + assertEquals("User(s) not found with email(s): nonexistent@example.com", exception.getMessage()); + verify(userRepository, never()).deleteAll(any()); + verify(userOrganizationRepository, never()).removeMultipleUserOrganizationsByEmail(any()); + } + + @Test + void testDeleteMultipleUsersByEmail_EmptyList() { + List emails = List.of(); + + when(userRepository.findByEmailIn(emails)).thenReturn(List.of()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> userManagementService.deleteMultipleUsersByEmail(emails)); + + assertEquals("No valid user emails provided", exception.getMessage()); + verify(userRepository, never()).deleteAll(any()); + } + + @Test + void testDeleteMultipleUsersByEmail_AllNotFound() { + List emails = List.of("nonexistent1@example.com", "nonexistent2@example.com"); + + when(userRepository.findByEmailIn(emails)).thenReturn(List.of()); + + EntityNotFoundException exception = assertThrows(EntityNotFoundException.class, + () -> userManagementService.deleteMultipleUsersByEmail(emails)); + + assertEquals("User(s) not found with email(s): nonexistent1@example.com, nonexistent2@example.com", + exception.getMessage()); + verify(userRepository, never()).deleteAll(any()); + } + + // ==================== createUser Tests ==================== + + @Test + void testCreateUser_Success_SingleOrganization() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("USER"); + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + newUser.setFirstName("New"); + newUser.setLastName("User"); + newUser.setSuperUser(false); + + UserOrganization userOrg = new UserOrganization(); + userOrg.setUser(newUser); + userOrg.setOrganization(testOrganization); + userOrg.setRole(testRole); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + when(userOrganizationRepository.saveAll(anyList())).thenReturn(List.of(userOrg)); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + assertEquals("newuser@example.com", result.getEmail()); + verify(usersResource).create(any(UserRepresentation.class)); + verify(organizationRepository).findByName("TestOrg"); + verify(roleRepository).findByNameIgnoreCase("USER"); + verify(userOrganizationRepository).saveAll(anyList()); + } + + @Test + void testCreateUser_Success_MultipleOrganizations() { + UserOrganizationDto org1Dto = new UserOrganizationDto(); + org1Dto.setOrganization("TestOrg"); + org1Dto.setRole("ADMIN"); + + UserOrganizationDto org2Dto = new UserOrganizationDto(); + org2Dto.setOrganization("AnotherOrg"); + org2Dto.setRole("USER"); + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(org1Dto, org2Dto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + newUser.setFirstName("New"); + newUser.setLastName("User"); + newUser.setSuperUser(false); + + Organization anotherOrg = new Organization(); + anotherOrg.setId(2); + anotherOrg.setName("AnotherOrg"); + + Role adminRole = new Role(); + adminRole.setId(2); + adminRole.setName("ADMIN"); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(organizationRepository.findByName("AnotherOrg")).thenReturn(Optional.of(anotherOrg)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + when(roleRepository.findByNameIgnoreCase("ADMIN")).thenReturn(Optional.of(adminRole)); + when(userOrganizationRepository.saveAll(anyList())).thenReturn(new ArrayList<>()); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + assertEquals("newuser@example.com", result.getEmail()); + verify(usersResource).create(any(UserRepresentation.class)); + verify(organizationRepository).findByName("TestOrg"); + verify(organizationRepository).findByName("AnotherOrg"); + verify(roleRepository).findByNameIgnoreCase("ADMIN"); + verify(roleRepository).findByNameIgnoreCase("USER"); + verify(userOrganizationRepository).saveAll(argThat(list -> { + List listCopy = new ArrayList<>(); + list.forEach(listCopy::add); + return listCopy.size() == 2; + })); + } + + @Test + void testCreateUser_Success_NoOrganizations() { + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of() // No organizations + ); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + newUser.setFirstName("New"); + newUser.setLastName("User"); + newUser.setSuperUser(false); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(userOrganizationRepository.saveAll(anyList())).thenReturn(new ArrayList<>()); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + assertEquals("newuser@example.com", result.getEmail()); + verify(usersResource).create(any(UserRepresentation.class)); + verify(userOrganizationRepository).saveAll(argThat(list -> { + List listCopy = new ArrayList<>(); + list.forEach(listCopy::add); + return listCopy.size() == 0; + })); + verify(organizationRepository, never()).findByName(anyString()); + verify(roleRepository, never()).findByNameIgnoreCase(anyString()); + } + + @Test + void testCreateUser_Success_SuperUser() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("ADMIN"); + + UserDto userDto = new UserDto( + "superuser@example.com", + "Super", + "User", + true, // Super user + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("superuser@example.com"); + newUser.setFirstName("Super"); + newUser.setLastName("User"); + newUser.setSuperUser(true); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("superuser@example.com")).thenReturn(newUser); + + when(userRepository.save(newUser)).thenReturn(newUser); + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("ADMIN")).thenReturn(Optional.of(testRole)); + when(userOrganizationRepository.saveAll(anyList())).thenReturn(new ArrayList<>()); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + assertEquals("superuser@example.com", result.getEmail()); + assertTrue(result.getSuperUser()); + verify(usersResource).create(any(UserRepresentation.class)); + verify(userRepository).save(newUser); + } + + @Test + void testCreateUser_OrganizationNotFound() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("NonexistentOrg"); + orgDto.setRole("USER"); + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("NonexistentOrg")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> userManagementService.createUser(userDto)); + + assertTrue(exception.getMessage().contains("Organization not found")); + assertTrue(exception.getMessage().contains("NonexistentOrg")); + verify(usersResource).create(any(UserRepresentation.class)); + verify(organizationRepository).findByName("NonexistentOrg"); + verify(userOrganizationRepository, never()).saveAll(anyList()); + } + + @Test + void testCreateUser_RoleNotFound() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("NONEXISTENT_ROLE"); + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("NONEXISTENT_ROLE")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> userManagementService.createUser(userDto)); + + assertTrue(exception.getMessage().contains("Role not found")); + assertTrue(exception.getMessage().contains("NONEXISTENT_ROLE")); + verify(usersResource).create(any(UserRepresentation.class)); + verify(organizationRepository).findByName("TestOrg"); + verify(roleRepository).findByNameIgnoreCase("NONEXISTENT_ROLE"); + verify(userOrganizationRepository, never()).saveAll(anyList()); + } + + @Test + void testCreateUser_CaseInsensitiveOrganizationLookup() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("testorg"); // lowercase + orgDto.setRole("USER"); + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("testorg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + when(userOrganizationRepository.saveAll(anyList())).thenReturn(new ArrayList<>()); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + verify(usersResource).create(any(UserRepresentation.class)); + verify(organizationRepository).findByName("testorg"); + } + + @Test + void testCreateUser_CaseInsensitiveRoleLookup() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("admin"); // lowercase + + UserDto userDto = new UserDto( + "newuser@example.com", + "New", + "User", + false, + List.of(orgDto)); + + User newUser = new User(); + newUser.setEmail("newuser@example.com"); + + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(201); + + Keycloak keycloak = mock(Keycloak.class); + RealmResource realmResource = mock(RealmResource.class); + UsersResource usersResource = mock(UsersResource.class); + String realm = "cvmanager"; + + when(keycloakAdminConfig.getRealm()).thenReturn(realm); + when(keycloakAdminConfig.keyCloakBuilder()).thenReturn(keycloak); + when(keycloak.realm(realm)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + when(userRepository.findByEmail("newuser@example.com")).thenReturn(newUser); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("admin")).thenReturn(Optional.of(testRole)); + when(userOrganizationRepository.saveAll(anyList())).thenReturn(new ArrayList<>()); + + User result = userManagementService.createUser(userDto); + + assertNotNull(result); + verify(usersResource).create(any(UserRepresentation.class)); + verify(roleRepository).findByNameIgnoreCase("admin"); + } + + // ==================== createUserOrgRelationship Tests ==================== + + @Test + void testCreateUserOrgRelationship_Success() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("USER"); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals(testOrganization, result.getOrganization()); + assertEquals(testUser, result.getUser()); + assertEquals(testRole, result.getRole()); + verify(organizationRepository).findByName("TestOrg"); + verify(roleRepository).findByNameIgnoreCase("USER"); + } + + @Test + void testCreateUserOrgRelationship_WithAdminRole() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("ADMIN"); + + Role adminRole = new Role(); + adminRole.setId(2); + adminRole.setName("ADMIN"); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("ADMIN")).thenReturn(Optional.of(adminRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals("ADMIN", result.getRole().getName()); + verify(roleRepository).findByNameIgnoreCase("ADMIN"); + } + + @Test + void testCreateUserOrgRelationship_WithOperatorRole() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("OPERATOR"); + + Role operatorRole = new Role(); + operatorRole.setId(3); + operatorRole.setName("OPERATOR"); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("OPERATOR")).thenReturn(Optional.of(operatorRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals("OPERATOR", result.getRole().getName()); + verify(roleRepository).findByNameIgnoreCase("OPERATOR"); + } + + @Test + void testCreateUserOrgRelationship_OrganizationNotFound() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("NonexistentOrg"); + orgDto.setRole("USER"); + + when(organizationRepository.findByName("NonexistentOrg")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> userManagementService.createUserOrgRelationship(orgDto, testUser)); + + assertTrue(exception.getMessage().contains("Organization not found")); + assertTrue(exception.getMessage().contains("NonexistentOrg")); + verify(organizationRepository).findByName("NonexistentOrg"); + verify(roleRepository, never()).findByNameIgnoreCase(anyString()); + } + + @Test + void testCreateUserOrgRelationship_RoleNotFound() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("INVALID_ROLE"); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("INVALID_ROLE")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> userManagementService.createUserOrgRelationship(orgDto, testUser)); + + assertTrue(exception.getMessage().contains("Role not found")); + assertTrue(exception.getMessage().contains("INVALID_ROLE")); + verify(organizationRepository).findByName("TestOrg"); + verify(roleRepository).findByNameIgnoreCase("INVALID_ROLE"); + } + + @Test + void testCreateUserOrgRelationship_CaseInsensitiveOrganization() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TESTORG"); // uppercase + orgDto.setRole("USER"); + + when(organizationRepository.findByName("TESTORG")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals(testOrganization, result.getOrganization()); + verify(organizationRepository).findByName("TESTORG"); + } + + @Test + void testCreateUserOrgRelationship_CaseInsensitiveRole() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("user"); // lowercase + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("user")).thenReturn(Optional.of(testRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals(testRole, result.getRole()); + verify(roleRepository).findByNameIgnoreCase("user"); + } + + @Test + void testCreateUserOrgRelationship_DifferentOrganization() { + Organization anotherOrg = new Organization(); + anotherOrg.setId(2); + anotherOrg.setName("AnotherOrg"); + + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("AnotherOrg"); + orgDto.setRole("USER"); + + when(organizationRepository.findByName("AnotherOrg")).thenReturn(Optional.of(anotherOrg)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals(anotherOrg, result.getOrganization()); + assertEquals("AnotherOrg", result.getOrganization().getName()); + verify(organizationRepository).findByName("AnotherOrg"); + } + + @Test + void testCreateUserOrgRelationship_UserAssignment() { + UserOrganizationDto orgDto = new UserOrganizationDto(); + orgDto.setOrganization("TestOrg"); + orgDto.setRole("USER"); + + when(organizationRepository.findByName("TestOrg")).thenReturn(Optional.of(testOrganization)); + when(roleRepository.findByNameIgnoreCase("USER")).thenReturn(Optional.of(testRole)); + + UserOrganization result = userManagementService.createUserOrgRelationship(orgDto, testUser); + + assertNotNull(result); + assertEquals(testUser, result.getUser()); + assertEquals(testUser.getEmail(), result.getUser().getEmail()); + } +} \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/EmailTaskTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/EmailTaskTest.java index 2a4896692..0b8ecb0b5 100644 --- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/EmailTaskTest.java +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/EmailTaskTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.keycloak.representations.idm.UserRepresentation; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -10,21 +9,25 @@ import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.ConnectionOfTravelNotification; import us.dot.its.jpo.conflictmonitor.monitor.models.notifications.Notification; import us.dot.its.jpo.ode.api.accessors.notifications.active_notification.ActiveNotificationRepository; -import us.dot.its.jpo.ode.api.models.EmailFrequency; +import us.dot.its.jpo.ode.api.emails.generators.IntersectionNotificationSummaryEmailGenerator; +import us.dot.its.jpo.ode.api.models.emails.EmailCategory; +import us.dot.its.jpo.ode.api.models.emails.EmailContent; +import us.dot.its.jpo.ode.api.models.emails.EmailFrequency; +import us.dot.its.jpo.ode.api.models.emails.EmailRecipient; import us.dot.its.jpo.ode.api.services.EmailService; import java.time.Instant; import java.util.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class EmailTaskTest { + private IntersectionNotificationSummaryEmailGenerator emailGenerator; private EmailService emailService; private ActiveNotificationRepository activeNotificationRepo; private EmailTask emailTask; @@ -35,7 +38,8 @@ class EmailTaskTest { void setUp() { emailService = mock(EmailService.class); activeNotificationRepo = mock(ActiveNotificationRepository.class); - emailTask = new EmailTask(emailService, activeNotificationRepo, maximumResponseSize); + emailGenerator = mock(IntersectionNotificationSummaryEmailGenerator.class); + emailTask = new EmailTask(emailService, activeNotificationRepo, maximumResponseSize, emailGenerator); } Notification createNotification(String key, String heading, String text, int intersectionId, long generatedAt) { @@ -82,24 +86,6 @@ void testGetNewNotificationsNoneNew() { assertThat(result).isEmpty(); } - @Test - void testGetEmailHeadingFormat() { - String heading = emailTask.getEmailHeading(); - assertThat(heading).startsWith("New Conflict Monitor Notifications: "); - } - - @Test - void testGetEmailTextFormat() { - Notification n1 = createNotification("k1", "heading", "text", 1, 1234567890000L); - List notifications = Collections.singletonList(n1); - String text = emailTask.getEmailText(notifications); - - assertThat(text).contains("Notification : heading"); - assertThat(text).contains("text"); - assertThat(text).contains("Intersection ID: 1"); - assertThat(text).contains("Generated At: "); - } - @Test void testSendAlwaysNotificationsFirstRunSetsLastAlwaysList() { Notification n1 = createNotification("k1", "h1", "t1", 1, 1000); @@ -111,7 +97,7 @@ void testSendAlwaysNotificationsFirstRunSetsLastAlwaysList() { emailTask.sendAlwaysNotifications(); // Should set lastAlwaysList and not send email - verify(emailService, never()).emailList(anyList(), anyString(), anyString()); + verify(emailService, never()).sendEmails(anyList(), any()); } @Test @@ -129,12 +115,17 @@ void testSendAlwaysNotificationsSendsEmailOnNew() { .thenReturn(page2); // second call emailTask.sendAlwaysNotifications(); // sets lastAlwaysList - List recipients = Collections.singletonList(new UserRepresentation()); - when(emailService.getNotificationEmailList(EmailFrequency.ALWAYS)).thenReturn(recipients); + + List recipients = List.of(new EmailRecipient("email", "name")); + when(emailService.getUsersForNotificationType(EmailCategory.INTERSECTION_NOTIFICATION_SUMMARY, + EmailFrequency.IMMEDIATE)).thenReturn(recipients); + + EmailContent content = new EmailContent("subject", "body"); + when(emailGenerator.generateEmailBody(any())).thenReturn(content); emailTask.sendAlwaysNotifications(); // should send email - verify(emailService).emailList(eq(recipients), anyString(), contains("Notification : h2")); + verify(emailService).sendEmails(eq(recipients), eq(content)); } @Test @@ -147,7 +138,8 @@ void testSendHourlyNotificationsFirstRunSetsLastHourList() { emailTask.sendHourlyNotifications(); - verify(emailService, never()).emailList(anyList(), anyString(), anyString()); + // Should set lastHourList and not send email + verify(emailService, never()).sendEmails(anyList(), any()); } @Test @@ -165,12 +157,17 @@ void testSendHourlyNotificationsSendsEmailOnNew() { .thenReturn(page2); emailTask.sendHourlyNotifications(); - List recipients = Collections.singletonList(new UserRepresentation()); - when(emailService.getNotificationEmailList(EmailFrequency.ALWAYS)).thenReturn(recipients); + + List recipients = List.of(new EmailRecipient("email", "name")); + when(emailService.getUsersForNotificationType(EmailCategory.INTERSECTION_NOTIFICATION_SUMMARY, + EmailFrequency.ONCE_PER_HOUR)).thenReturn(recipients); + + EmailContent content = new EmailContent("subject", "body"); + when(emailGenerator.generateEmailBody(any())).thenReturn(content); emailTask.sendHourlyNotifications(); - verify(emailService).emailList(eq(recipients), anyString(), contains("Notification : h2")); + verify(emailService).sendEmails(eq(recipients), eq(content)); } @Test @@ -183,7 +180,7 @@ void testSendDailyNotificationsFirstRunSetsLastDayList() { emailTask.sendDailyNotifications(); - verify(emailService, never()).emailList(anyList(), anyString(), anyString()); + verify(emailService, never()).sendEmails(anyList(), any()); } @Test @@ -201,12 +198,17 @@ void testSendDailyNotificationsSendsEmailOnNew() { .thenReturn(page2); emailTask.sendDailyNotifications(); - List recipients = Collections.singletonList(new UserRepresentation()); - when(emailService.getNotificationEmailList(EmailFrequency.ALWAYS)).thenReturn(recipients); + + List recipients = List.of(new EmailRecipient("email", "name")); + when(emailService.getUsersForNotificationType(EmailCategory.INTERSECTION_NOTIFICATION_SUMMARY, + EmailFrequency.ONCE_PER_DAY)).thenReturn(recipients); + + EmailContent content = new EmailContent("subject", "body"); + when(emailGenerator.generateEmailBody(any())).thenReturn(content); emailTask.sendDailyNotifications(); - verify(emailService).emailList(eq(recipients), anyString(), contains("Notification : h2")); + verify(emailService).sendEmails(eq(recipients), eq(content)); } @Test @@ -219,7 +221,7 @@ void testSendWeeklyNotificationsFirstRunSetsLastWeekList() { emailTask.sendWeeklyNotifications(); - verify(emailService, never()).emailList(anyList(), anyString(), anyString()); + verify(emailService, never()).sendEmails(anyList(), any()); } @Test @@ -237,12 +239,17 @@ void testSendWeeklyNotificationsSendsEmailOnNew() { .thenReturn(page2); emailTask.sendWeeklyNotifications(); - List recipients = Collections.singletonList(new UserRepresentation()); - when(emailService.getNotificationEmailList(EmailFrequency.ALWAYS)).thenReturn(recipients); + + List recipients = List.of(new EmailRecipient("email", "name")); + when(emailService.getUsersForNotificationType(EmailCategory.INTERSECTION_NOTIFICATION_SUMMARY, + EmailFrequency.ONCE_PER_WEEK)).thenReturn(recipients); + + EmailContent content = new EmailContent("subject", "body"); + when(emailGenerator.generateEmailBody(any())).thenReturn(content); emailTask.sendWeeklyNotifications(); - verify(emailService).emailList(eq(recipients), anyString(), contains("Notification : h2")); + verify(emailService).sendEmails(eq(recipients), eq(content)); } @Test @@ -255,7 +262,7 @@ void testSendMonthlyNotificationsFirstRunSetsLastMonthList() { emailTask.sendMonthlyNotifications(); - verify(emailService, never()).emailList(anyList(), anyString(), anyString()); + verify(emailService, never()).sendEmails(anyList(), any()); } @Test @@ -273,11 +280,16 @@ void testSendMonthlyNotificationsSendsEmailOnNew() { .thenReturn(page2); emailTask.sendMonthlyNotifications(); - List recipients = Collections.singletonList(new UserRepresentation()); - when(emailService.getNotificationEmailList(EmailFrequency.ALWAYS)).thenReturn(recipients); + + List recipients = List.of(new EmailRecipient("email", "name")); + when(emailService.getUsersForNotificationType(EmailCategory.INTERSECTION_NOTIFICATION_SUMMARY, + EmailFrequency.ONCE_PER_MONTH)).thenReturn(recipients); + + EmailContent content = new EmailContent("subject", "body"); + when(emailGenerator.generateEmailBody(any())).thenReturn(content); emailTask.sendMonthlyNotifications(); - verify(emailService).emailList(eq(recipients), anyString(), contains("Notification : h2")); + verify(emailService).sendEmails(eq(recipients), eq(content)); } } \ No newline at end of file diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/utils/AuthUtilsTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/utils/AuthUtilsTest.java new file mode 100644 index 000000000..596c723f6 --- /dev/null +++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/utils/AuthUtilsTest.java @@ -0,0 +1,58 @@ +package us.dot.its.jpo.ode.api.utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class AuthUtilsTest { + + @Nested + @DisplayName("Tests for checkRoleAbove method") + class CheckRoleAboveTests { + @Test + void testCheckRoleAbove_AdminAboveOperator() { + assertTrue(AuthUtils.checkRoleAbove("ADMIN", "OPERATOR")); + } + + @Test + void testCheckRoleAbove_AdminAboveUser() { + assertTrue(AuthUtils.checkRoleAbove("ADMIN", "USER")); + } + + @Test + void testCheckRoleAbove_OperatorAboveUser() { + assertTrue(AuthUtils.checkRoleAbove("OPERATOR", "USER")); + } + + @Test + void testCheckRoleAbove_SameRole() { + assertTrue(AuthUtils.checkRoleAbove("ADMIN", "ADMIN")); + assertTrue(AuthUtils.checkRoleAbove("OPERATOR", "OPERATOR")); + assertTrue(AuthUtils.checkRoleAbove("USER", "USER")); + } + + @Test + void testCheckRoleAbove_UserNotAboveOperator() { + assertFalse(AuthUtils.checkRoleAbove("USER", "OPERATOR")); + } + + @Test + void testCheckRoleAbove_OperatorNotAboveAdmin() { + assertFalse(AuthUtils.checkRoleAbove("OPERATOR", "ADMIN")); + } + + @Test + void testCheckRoleAbove_NullRole() { + assertFalse(AuthUtils.checkRoleAbove(null, "ADMIN")); + } + + @Test + void testCheckRoleAbove_CaseInsensitive() { + assertTrue(AuthUtils.checkRoleAbove("admin", "user")); + assertTrue(AuthUtils.checkRoleAbove("Admin", "User")); + } + } +} diff --git a/services/intersection-api/api/src/test/resources/application-integration-test.yaml b/services/intersection-api/api/src/test/resources/application-integration-test.yaml new file mode 100644 index 000000000..5be15ae06 --- /dev/null +++ b/services/intersection-api/api/src/test/resources/application-integration-test.yaml @@ -0,0 +1,123 @@ +# Integration test profile +# +# PostgreSQL (PostGIS) is provided automatically by Testcontainers via the tc: JDBC URL +# scheme — no container code required. Testcontainers intercepts the URL, pulls the +# postgis/postgis:17-3.5 image, and wires the connection before Hibernate runs DDL. +# +# MongoDB is provided by a shared MongoDBContainer bean declared in TestcontainersConfiguration. +# The @ServiceConnection annotation on that bean automatically overrides spring.data.mongodb.uri +# with the container's actual URI. The placeholder URI below is never used in practice. +# +# Usage in tests: +# @SpringBootTest +# @ActiveProfiles("integration-test") +# @Import(TestcontainersConfiguration.class) +# class MyIntegrationTest { ... } + +server: + port: 8089 + +### Keycloak — not needed for service-layer integration tests (no HTTP requests) +keycloak: + realm: cvmanager + auth-server-url: http://localhost:8084 + client-id: cvmanager-api + client-secret: test-secret + +spring.security.oauth2.resourceserver.jwt: + issuer-uri: http://localhost:8084/realms/cvmanager + jwk-set-uri: http://localhost:8084/realms/cvmanager/protocol/openid-connect/certs + +spring.kafka.bootstrap-servers: localhost:9092 + +kafka.linger_ms: 50 + +kafka.topics: + autoCreateTopics: false + numPartitions: 1 + numReplicas: 1 + createTopics: + - name: topic.ProcessedSpat + cleanupPolicy: delete + retentionMs: 300000 + - name: topic.ProcessedMap + cleanupPolicy: delete + retentionMs: 300000 + +conflict: + monitor: + api: + kafka-consumers-always-on: false + +spring: + data: + mongodb: + # uri overrides host/port/credentials at runtime via MongoConfig.configureClientSettings; + # the individual properties below must be present to satisfy @Value injection in MongoConfig + # but are not used for the actual connection when uri is set. + uri: mongodb://localhost:27017/testdb + database: testdb + host: localhost + port: '27017' + username: test + password: test + authenticationDatabase: admin + # PostgreSQL — Testcontainers starts a postgis/postgis:17-3.5 container automatically + datasource: + url: jdbc:tc:postgis:17-3.5:///testdb + username: postgres + password: postgres + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + hikari: + # Limit pool size to prevent "too many clients" errors when multiple test contexts + # share the same PostgreSQL container. Each @SpringBootTest with different mock + # configurations creates a separate context (and connection pool). With ~20 test + # classes and the default pool size of 10, we'd exceed PostgreSQL's max_connections (100). + maximum-pool-size: 2 + minimum-idle: 1 + connection-timeout: 30000 + jpa: + hibernate: + ddl-auto: create-only + mail: + host: localhost + port: 1025 + properties: + mail: + smtp: + auth: false + starttls: + enable: false + cache: + cache-names: [] + caffeine: + spec: maximumSize=100000,expireAfterWrite=1h + +enable: + api: true + email: false + report: false + asn1-decoder: false + +cmServerURL: http://localhost:8082 +mongoTimeoutMs: 5000 +cors: '*' +maximumResponseSize: 10000 + +bucket4j: + enabled: false + filters: + - cache-name: email-rate-limit + url: /emails/.* + strategy: all + rate-limits: + - cache-key: "@jwtSubjectExtractor.extractSubject(getHeader('Authorization')) != null ? @jwtSubjectExtractor.extractSubject(getHeader('Authorization')) : getRemoteAddr()" + bandwidths: + - capacity: 3 + time: 1 + unit: hours + - cache-key: "'email_global'" + bandwidths: + - capacity: 6 + time: 1 + unit: hours diff --git a/services/intersection-api/api/src/test/resources/application.yaml b/services/intersection-api/api/src/test/resources/application.yaml new file mode 100644 index 000000000..0170f850b --- /dev/null +++ b/services/intersection-api/api/src/test/resources/application.yaml @@ -0,0 +1,27 @@ +# Use the Docker Compose PostgreSQL container for tests +spring: + datasource: + url: jdbc:postgresql://localhost:5432/postgres + username: postgres + password: password + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + + # Disable test containers embedded database + test: + database: + replace: none + +# Change this if you need more information from logs during a test run. This prevents log spam during routine test +# execution by limiting the logs to error level logs +logging: + level: + root: ERROR + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/api_error_email_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/api_error_email_snapshot.html new file mode 100644 index 000000000..1e1992469 --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/api_error_email_snapshot.html @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/firmware_upgrade_failure_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/firmware_upgrade_failure_snapshot.html new file mode 100644 index 000000000..cea9963aa --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/firmware_upgrade_failure_snapshot.html @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/intersection_notification_summary_email_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/intersection_notification_summary_email_snapshot.html new file mode 100644 index 000000000..cfb429951 --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/intersection_notification_summary_email_snapshot.html @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/message_count_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/message_count_snapshot.html new file mode 100644 index 000000000..2eff36525 --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/message_count_snapshot.html @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/rsu_error_summary_email_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/rsu_error_summary_email_snapshot.html new file mode 100644 index 000000000..34976c5da --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/rsu_error_summary_email_snapshot.html @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/api/src/test/resources/snapshots/emails/support_request_email_multiline_snapshot.html b/services/intersection-api/api/src/test/resources/snapshots/emails/support_request_email_multiline_snapshot.html new file mode 100644 index 000000000..179f0acbb --- /dev/null +++ b/services/intersection-api/api/src/test/resources/snapshots/emails/support_request_email_multiline_snapshot.html @@ -0,0 +1,308 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/api_error_example.html b/services/intersection-api/docs/email_examples/api_error_example.html new file mode 100644 index 000000000..f07bf8ff0 --- /dev/null +++ b/services/intersection-api/docs/email_examples/api_error_example.html @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/api_error_example.png b/services/intersection-api/docs/email_examples/api_error_example.png new file mode 100644 index 000000000..d7f5cb3e1 Binary files /dev/null and b/services/intersection-api/docs/email_examples/api_error_example.png differ diff --git a/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.html b/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.html new file mode 100644 index 000000000..dd24d2a62 --- /dev/null +++ b/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.html @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.png b/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.png new file mode 100644 index 000000000..e1a6781c1 Binary files /dev/null and b/services/intersection-api/docs/email_examples/firmware_upgrade_failure_example.png differ diff --git a/services/intersection-api/docs/email_examples/intersection_notification_summary_example.html b/services/intersection-api/docs/email_examples/intersection_notification_summary_example.html new file mode 100644 index 000000000..6071f4748 --- /dev/null +++ b/services/intersection-api/docs/email_examples/intersection_notification_summary_example.html @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/intersection_notification_summary_example.png b/services/intersection-api/docs/email_examples/intersection_notification_summary_example.png new file mode 100644 index 000000000..4d92c0775 Binary files /dev/null and b/services/intersection-api/docs/email_examples/intersection_notification_summary_example.png differ diff --git a/services/intersection-api/docs/email_examples/message_counts_example.html b/services/intersection-api/docs/email_examples/message_counts_example.html new file mode 100644 index 000000000..b05db34bf --- /dev/null +++ b/services/intersection-api/docs/email_examples/message_counts_example.html @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/message_counts_example.png b/services/intersection-api/docs/email_examples/message_counts_example.png new file mode 100644 index 000000000..be9a95457 Binary files /dev/null and b/services/intersection-api/docs/email_examples/message_counts_example.png differ diff --git a/services/intersection-api/docs/email_examples/rsu_error_summary_example.html b/services/intersection-api/docs/email_examples/rsu_error_summary_example.html new file mode 100644 index 000000000..25dbe6678 --- /dev/null +++ b/services/intersection-api/docs/email_examples/rsu_error_summary_example.html @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/rsu_error_summary_example.png b/services/intersection-api/docs/email_examples/rsu_error_summary_example.png new file mode 100644 index 000000000..b8da16b8e Binary files /dev/null and b/services/intersection-api/docs/email_examples/rsu_error_summary_example.png differ diff --git a/services/intersection-api/docs/email_examples/support_request_example.html b/services/intersection-api/docs/email_examples/support_request_example.html new file mode 100644 index 000000000..5543ab479 --- /dev/null +++ b/services/intersection-api/docs/email_examples/support_request_example.html @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/services/intersection-api/docs/email_examples/support_request_example.png b/services/intersection-api/docs/email_examples/support_request_example.png new file mode 100644 index 000000000..7d8c1ab20 Binary files /dev/null and b/services/intersection-api/docs/email_examples/support_request_example.png differ diff --git a/services/requirements.txt b/services/requirements.txt index f7e951deb..97f59f09f 100644 --- a/services/requirements.txt +++ b/services/requirements.txt @@ -23,7 +23,7 @@ pytz==2023.3.post1 Werkzeug==3.0.0 uuid==1.30 multidict==6.0.5 -python-keycloak==5.5.1 +python-keycloak==5.8.1 fabric==3.2.2 paramiko==3.5.0 scp==0.15.0 @@ -38,4 +38,5 @@ google-cloud-bigquery==3.29.0 pandas==2.2.3 shapely==2.0.7 db-dtypes==1.4.0 -PyJWT==2.10.1 \ No newline at end of file +trio==0.31.0 +PyJWT==2.10.1 diff --git a/services/rsu-info-bridge/.gitattributes b/services/rsu-info-bridge/.gitattributes new file mode 100644 index 000000000..3b41682ac --- /dev/null +++ b/services/rsu-info-bridge/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/services/rsu-info-bridge/.gitignore b/services/rsu-info-bridge/.gitignore new file mode 100644 index 000000000..667aaef0c --- /dev/null +++ b/services/rsu-info-bridge/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/services/rsu-info-bridge/.mvn/wrapper/maven-wrapper.properties b/services/rsu-info-bridge/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..8dea6c227 --- /dev/null +++ b/services/rsu-info-bridge/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/services/rsu-info-bridge/README.md b/services/rsu-info-bridge/README.md new file mode 100644 index 000000000..134578f9c --- /dev/null +++ b/services/rsu-info-bridge/README.md @@ -0,0 +1,88 @@ +# RSU Info Bridge + +## Overview +The RSU Info Bridge is a Spring Boot-based microservice designed to provide a standardized interface for external systems to retrieve information about Roadside Units (RSUs). + +## Getting Started + +### Prerequisites +- Java 25 JDK +- Maven 3.9+ + +### Building the Project +To build the project and run tests, use the following command: + +```bash +./mvnw clean install +``` + +## Running the Application + +### Using Maven +Run the application using the Spring Boot Maven plugin: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.profiles=local +``` + +Spring Boot's Docker Compose integration will automatically start the PostgreSQL database container from the root `docker-compose.yml` when the application starts. + +The service will be available at `http://localhost:16543` (default port). + +### Using Docker Compose +First, build the Docker image (see [Building Docker Images](#building-docker-images) below). + +Then, copy the sample environment file: + +```bash +cp sample.env .env +``` + +Then start the service using Docker Compose: + +```bash +docker compose up -d +``` + +This will start both the PostgreSQL database (from the root docker-compose.yml) and the RSU Info Bridge service. The required profiles are configured in the `.env` file via `COMPOSE_PROFILES`. + +### Accessing the API Documentation +Once the application is running, you can access the Swagger UI to view and interact with the OpenAPI documentation: + +``` +http://localhost:16543/swagger-ui.html +``` + +The OpenAPI specification is also available in JSON format at: + +``` +http://localhost:16543/v3/api-docs +``` + +## Building Docker Images + +### Jib (Recommended for GKE) +For deployment to Google Kubernetes Engine (GKE), use the Jib Maven plugin to build the Docker image: + +```bash +./mvnw compile jib:dockerBuild +``` + +### Spring Boot Build Image + +> **Warning:** Images built with `spring-boot:build-image` may cause `CreateContainerError` in GKE due to "too many symbolic links". Use the Jib approach above for GKE deployments. + +For local development or non-GKE environments, you can use the Spring Boot Maven plugin: + +```bash +./mvnw spring-boot:build-image +``` + +## Configuration +Configuration is managed via `src/main/resources/application.yaml`. + +## Running Tests + +```bash +./mvnw test +``` diff --git a/services/rsu-info-bridge/compose.yml b/services/rsu-info-bridge/compose.yml new file mode 100644 index 000000000..a7c479ec4 --- /dev/null +++ b/services/rsu-info-bridge/compose.yml @@ -0,0 +1,16 @@ +include: + - path: ../../docker-compose.yml + env_file: ../../.env + +services: + rsu-info-bridge: + image: rsu-info-bridge:0.0.1-SNAPSHOT + container_name: rsu-info-bridge + ports: + - "${RSU_INFO_BRIDGE_PORT:-16543}:${RSU_INFO_BRIDGE_PORT:-16543}" + env_file: + - .env + profiles: + - rsu-info-bridge + depends_on: + - cvmanager_postgres diff --git a/services/rsu-info-bridge/mvnw b/services/rsu-info-bridge/mvnw new file mode 100755 index 000000000..bd8896bf2 --- /dev/null +++ b/services/rsu-info-bridge/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/services/rsu-info-bridge/mvnw.cmd b/services/rsu-info-bridge/mvnw.cmd new file mode 100755 index 000000000..92450f932 --- /dev/null +++ b/services/rsu-info-bridge/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/services/rsu-info-bridge/pom.xml b/services/rsu-info-bridge/pom.xml new file mode 100644 index 000000000..efdba350a --- /dev/null +++ b/services/rsu-info-bridge/pom.xml @@ -0,0 +1,169 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.3 + + + com.trihydro + rsu-info-bridge + 0.0.1-SNAPSHOT + rsu-info-bridge + rsu-info-bridge + + + + + + + + + + + + + + + 25 + 1.5.5.Final + 0.2.0 + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-docker-compose + runtime + true + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.1 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.locationtech.jts + jts-core + 1.20.0 + + + org.hibernate.orm + hibernate-spatial + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + test + + + org.flywaydb + flyway-database-postgresql + test + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + + + src/test/resources + + + ../../resources/db/migration + db/migration + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + com.google.cloud.tools + jib-maven-plugin + 3.5.1 + + + + + diff --git a/services/rsu-info-bridge/sample.env b/services/rsu-info-bridge/sample.env new file mode 100644 index 000000000..10014c66c --- /dev/null +++ b/services/rsu-info-bridge/sample.env @@ -0,0 +1,13 @@ +# RSU Info Bridge Environment Variables + +## Docker Compose Profiles +COMPOSE_PROFILES=cvmanager_postgres,rsu-info-bridge + +## Port to expose the API on +RSU_INFO_BRIDGE_PORT=16543 + +## PostgreSQL Database Configuration +POSTGRES_SERVER_URL=jdbc:postgresql://host.docker.internal:5432 +POSTGRES_DB=postgres +PG_DB_USER=postgres +PG_DB_PASS=postgres diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplication.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplication.java new file mode 100644 index 000000000..824ea14b2 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplication.java @@ -0,0 +1,13 @@ +package com.trihydro.rsuinfobridge; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RsuInfoBridgeApplication { + + public static void main(String[] args) { + SpringApplication.run(RsuInfoBridgeApplication.class, args); + } + +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/controller/RsuController.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/controller/RsuController.java new file mode 100644 index 000000000..5e2df637c --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/controller/RsuController.java @@ -0,0 +1,37 @@ +package com.trihydro.rsuinfobridge.controller; + +import com.trihydro.rsuinfobridge.mapper.RsuDtoMapper; +import com.trihydro.rsuinfobridge.models.dtos.RsuDto; +import com.trihydro.rsuinfobridge.service.RsuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/rsus") +@RequiredArgsConstructor +@Tag(name = "RSU", description = "Roadside Unit information endpoints") +public class RsuController { + private final RsuService rsuService; + private final RsuDtoMapper rsuDtoMapper; + + @GetMapping + @Operation(summary = "Get all RSUs", description = "Retrieves a list of all Roadside Units in the system") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved list of RSUs") + }) + public List getAll( + @Parameter(description = "Filter RSUs by TIM deposit enabled status", example = "false") + @RequestParam(defaultValue = "false") boolean timDepositEnabledOnly) { + return rsuDtoMapper.toDtoList(rsuService.getAll(timDepositEnabledOnly)); + } +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandler.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..05f562e37 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandler.java @@ -0,0 +1,83 @@ +package com.trihydro.rsuinfobridge.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * Global exception handler for the RSU Info Bridge application. + * Handles exceptions that can occur in the current API: + * - Type mismatches on request parameters + * - Unsupported HTTP methods + * - Database access errors + * - Unexpected runtime errors + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * Handle method argument type mismatch exceptions (e.g., invalid boolean parameter) + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ProblemDetail handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { + log.warn("Method argument type mismatch: {}", ex.getMessage()); + + String message = String.format("Parameter '%s' should be of type '%s'", + ex.getName(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message); + problemDetail.setTitle("Type Mismatch"); + + return problemDetail; + } + + /** + * Handle HTTP request method not supported exceptions + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ProblemDetail handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { + log.warn("HTTP method not supported: {}", ex.getMessage()); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.METHOD_NOT_ALLOWED, + String.format("HTTP method '%s' is not supported for this endpoint", ex.getMethod())); + problemDetail.setTitle("Method Not Allowed"); + + return problemDetail; + } + + /** + * Handle data access exceptions (database connectivity issues, constraint violations, etc.) + */ + @ExceptionHandler(DataAccessException.class) + public ProblemDetail handleDataAccessException(DataAccessException ex) { + log.error("Data access error: {}", ex.getMessage(), ex); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.SERVICE_UNAVAILABLE, "An error occurred while accessing the database"); + problemDetail.setTitle("Database Error"); + + return problemDetail; + } + + /** + * Handle all other unhandled exceptions + */ + @ExceptionHandler(Exception.class) + public ProblemDetail handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); + problemDetail.setTitle("Internal Server Error"); + + return problemDetail; + } +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapper.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapper.java new file mode 100644 index 000000000..37c0d08e6 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapper.java @@ -0,0 +1,33 @@ +package com.trihydro.rsuinfobridge.mapper; + +import java.util.List; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +import com.trihydro.rsuinfobridge.models.dtos.RsuDto; +import com.trihydro.rsuinfobridge.models.tables.Rsu; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public interface RsuDtoMapper { + String AUTHENTICATION_PROTOCOL = "SHA"; + String PRIVACY_PROTOCOL = "AES"; + + /** + * Convert Rsu entity to RsuDto + */ + @Mapping(source = "id", target = "id") + @Mapping(source = "ipv4Address.hostAddress", target = "ipv4Address") + @Mapping(source = "snmpProtocol.protocolCode", target = "snmpProtocol") + @Mapping(source = "snmpCredential.username", target = "snmpUsername") + @Mapping(source = "snmpCredential.password", target = "snmpPassword") + @Mapping(target = "authenticationProtocol", constant = AUTHENTICATION_PROTOCOL) + @Mapping(target = "privacyProtocol", constant = PRIVACY_PROTOCOL) + @Mapping(source = "geography.y", target = "latitude") + @Mapping(source = "geography.x", target = "longitude") + @Mapping(source = "rsuOption.timDeposit", target = "timDepositEnabled") + RsuDto toDto(Rsu rsu); + + List toDtoList(List rsus); +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/dtos/RsuDto.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/dtos/RsuDto.java new file mode 100644 index 000000000..1a0236e6c --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/dtos/RsuDto.java @@ -0,0 +1,42 @@ +package com.trihydro.rsuinfobridge.models.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@AllArgsConstructor +@Builder +@Schema(description = "Roadside Unit information") +public class RsuDto { + @Schema(description = "Unique identifier for the RSU", example = "1") + private String id; + + @Schema(description = "IPv4 address of the RSU", example = "10.10.10.10") + private String ipv4Address; + + @Schema(description = "SNMP protocol version", example = "NTCIP1218") + private String snmpProtocol; + + @Schema(description = "SNMP username for authentication", example = "myusername") + private String snmpUsername; + + @Schema(description = "SNMP password for authentication", example = "mypassword") + private String snmpPassword; + + @Schema(description = "Authentication protocol used", example = "SHA") + private String authenticationProtocol; + + @Schema(description = "Privacy protocol used", example = "AES") + private String privacyProtocol; + + @Schema(description = "Latitude coordinate of the RSU location", example = "39.73915") + private double latitude; + + @Schema(description = "Longitude coordinate of the RSU location", example = "-104.9847") + private double longitude; + + @Schema(description = "Indicates whether TIM (Traveler Information Message) deposit is enabled", example = "true") + private boolean timDepositEnabled; +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Manufacturer.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Manufacturer.java new file mode 100644 index 000000000..4bbefeacf --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Manufacturer.java @@ -0,0 +1,32 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "manufacturers") +public class Manufacturer { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "manufacturers_id_gen") + @SequenceGenerator(name = "manufacturers_id_gen", sequenceName = "manufacturers_manufacturer_id_seq", allocationSize = 1) + @Column(name = "manufacturer_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "name", nullable = false, length = 128) + private String name; + +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Organization.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Organization.java new file mode 100644 index 000000000..0cd04b16c --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Organization.java @@ -0,0 +1,36 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "organizations") +public class Organization { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "organizations_id_gen") + @SequenceGenerator(name = "organizations_id_gen", sequenceName = "organizations_organization_id_seq", allocationSize = 1) + @Column(name = "organization_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "name", nullable = false, length = 128) + private String name; + + @Size(max = 128) + @Column(name = "email", length = 128) + private String email; + +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Rsu.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Rsu.java new file mode 100644 index 000000000..64c554f84 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/Rsu.java @@ -0,0 +1,78 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.net.InetAddress; + +import org.locationtech.jts.geom.Point; + +@Getter +@Setter +@Entity +@Table(name = "rsus") +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Rsu { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rsus_id_gen") + @SequenceGenerator(name = "rsus_id_gen", sequenceName = "rsus_rsu_id_seq", allocationSize = 1) + @Column(name = "rsu_id", nullable = false) + private Integer id; + + @Column(name = "geography", columnDefinition = "geography not null") + private Point geography; + + @NotNull + @Column(name = "milepost", nullable = false) + private Double milepost; + + @NotNull + @Column(name = "ipv4_address", nullable = false) + private InetAddress ipv4Address; + + @Size(max = 128) + @NotNull + @Column(name = "serial_number", nullable = false, length = 128) + private String serialNumber; + + @Size(max = 128) + @NotNull + @Column(name = "iss_scms_id", nullable = false, length = 128) + private String issScmsId; + + @Size(max = 128) + @NotNull + @Column(name = "primary_route", nullable = false, length = 128) + private String primaryRoute; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "model", nullable = false) + private RsuModel model; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "credential_id", nullable = false) + private RsuCredential credential; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "snmp_credential_id", nullable = false) + private SnmpCredential snmpCredential; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "snmp_protocol_id", nullable = false) + private SnmpProtocol snmpProtocol; + + @OneToOne(mappedBy = "rsu") + private RsuOption rsuOption; +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuCredential.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuCredential.java new file mode 100644 index 000000000..583cacb18 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuCredential.java @@ -0,0 +1,49 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "rsu_credentials") +public class RsuCredential { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rsu_credentials_id_gen") + @SequenceGenerator(name = "rsu_credentials_id_gen", sequenceName = "rsu_credentials_credential_id_seq", allocationSize = 1) + @Column(name = "credential_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "username", nullable = false, length = 128) + private String username; + + @Size(max = 128) + @NotNull + @Column(name = "password", nullable = false, length = 128) + private String password; + + @Size(max = 128) + @NotNull + @Column(name = "nickname", nullable = false, length = 128) + private String nickname; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_organization_id", nullable = false) + private Organization ownerOrganization; + +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuIntersection.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuIntersection.java new file mode 100644 index 000000000..6e849bb41 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuIntersection.java @@ -0,0 +1,31 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +/** + * Simplified entity for rsu_intersection table. + * Used for deleteAll operations in tests. + */ +@Getter +@Setter +@Entity +@Table(name = "rsu_intersection") +public class RsuIntersection { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rsu_intersection_id_gen") + @SequenceGenerator(name = "rsu_intersection_id_gen", sequenceName = "rsu_intersection_rsu_intersection_id_seq", allocationSize = 1) + @Column(name = "rsu_intersection_id", nullable = false) + private Integer id; + + @NotNull + @Column(name = "rsu_id", nullable = false) + private Integer rsuId; + + @NotNull + @Column(name = "intersection_id", nullable = false) + private Integer intersectionId; +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuModel.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuModel.java new file mode 100644 index 000000000..f6deecc2f --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuModel.java @@ -0,0 +1,45 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "rsu_models") +public class RsuModel { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rsu_models_id_gen") + @SequenceGenerator(name = "rsu_models_id_gen", sequenceName = "rsu_models_rsu_model_id_seq", allocationSize = 1) + @Column(name = "rsu_model_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "name", nullable = false, length = 128) + private String name; + + @Size(max = 128) + @NotNull + @Column(name = "supported_radio", nullable = false, length = 128) + private String supportedRadio; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "manufacturer", nullable = false) + private Manufacturer manufacturer; + +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOption.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOption.java new file mode 100644 index 000000000..7114c542a --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOption.java @@ -0,0 +1,33 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.ColumnDefault; + +@Getter +@Setter +@Entity +@Table(name = "rsu_options") +public class RsuOption { + @Id + @Column(name = "rsu_id", nullable = false) + private Integer id; + + @MapsId + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "rsu_id", nullable = false) + private Rsu rsu; + + @NotNull + @ColumnDefault("false") + @Column(name = "tim_deposit", nullable = false) + private Boolean timDeposit = false; + + @NotNull + @ColumnDefault("false") + @Column(name = "snmp_monitoring", nullable = false) + private Boolean snmpMonitoring = false; + +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOrganization.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOrganization.java new file mode 100644 index 000000000..6fd84d8b0 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/RsuOrganization.java @@ -0,0 +1,29 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "rsu_organization") +public class RsuOrganization { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rsu_organization_id_gen") + @SequenceGenerator(name = "rsu_organization_id_gen", sequenceName = "rsu_organization_rsu_organization_id_seq", allocationSize = 1) + @Column(name = "rsu_organization_id", nullable = false) + private Integer id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "rsu_id", nullable = false) + private Rsu rsu; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpCredential.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpCredential.java new file mode 100644 index 000000000..5cec23612 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpCredential.java @@ -0,0 +1,53 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "snmp_credentials") +public class SnmpCredential { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "snmp_credentials_id_gen") + @SequenceGenerator(name = "snmp_credentials_id_gen", sequenceName = "snmp_credentials_snmp_credential_id_seq", allocationSize = 1) + @Column(name = "snmp_credential_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "username", nullable = false, length = 128) + private String username; + + @Size(max = 128) + @NotNull + @Column(name = "password", nullable = false, length = 128) + private String password; + + @Size(max = 128) + @Column(name = "encrypt_password", length = 128) + private String encryptPassword; + + @Size(max = 128) + @NotNull + @Column(name = "nickname", nullable = false, length = 128) + private String nickname; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_organization_id", nullable = false) + private Organization ownerOrganization; + +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfig.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfig.java new file mode 100644 index 000000000..aab052269 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfig.java @@ -0,0 +1,61 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +import java.net.InetAddress; +import java.time.Instant; + +/** + * Entity for snmp_msgfwd_config table. + * Used for deleteAll operations in tests. + */ +@Getter +@Setter +@Entity +@Table(name = "snmp_msgfwd_config") +public class SnmpMsgfwdConfig { + @EmbeddedId + private SnmpMsgfwdConfigId id; + + @NotNull + @Column(name = "rsu_id", nullable = false, insertable = false, updatable = false) + private Integer rsuId; + + @NotNull + @Column(name = "msgfwd_type", nullable = false, insertable = false, updatable = false) + private Integer msgfwdType; + + @Size(max = 128) + @NotNull + @Column(name = "message_type", nullable = false, length = 128) + private String messageType; + + @NotNull + @Column(name = "dest_ipv4", nullable = false) + private InetAddress destIpv4; + + @NotNull + @Column(name = "dest_port", nullable = false) + private Integer destPort; + + @NotNull + @Column(name = "start_datetime", nullable = false) + private Instant startDatetime; + + @NotNull + @Column(name = "end_datetime", nullable = false) + private Instant endDatetime; + + @NotNull + @Column(name = "active", nullable = false, columnDefinition = "bit(1)") + private Boolean active; + + @NotNull + @Column(name = "security", nullable = false, columnDefinition = "bit(1)") + private Boolean security; +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfigId.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfigId.java new file mode 100644 index 000000000..6aae40d8e --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpMsgfwdConfigId.java @@ -0,0 +1,31 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@EqualsAndHashCode +@Embeddable +public class SnmpMsgfwdConfigId implements Serializable { + private static final long serialVersionUID = 228342654123605344L; + + @NotNull + @Column(name = "rsu_id", nullable = false) + private Integer rsuId; + + @NotNull + @Column(name = "msgfwd_type", nullable = false) + private Integer msgfwdType; + + @NotNull + @Column(name = "snmp_index", nullable = false) + private Integer snmpIndex; +} + diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpProtocol.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpProtocol.java new file mode 100644 index 000000000..3383b4e54 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/models/tables/SnmpProtocol.java @@ -0,0 +1,36 @@ +package com.trihydro.rsuinfobridge.models.tables; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "snmp_protocols") +public class SnmpProtocol { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "snmp_protocols_id_gen") + @SequenceGenerator(name = "snmp_protocols_id_gen", sequenceName = "snmp_protocols_snmp_protocol_id_seq", allocationSize = 1) + @Column(name = "snmp_protocol_id", nullable = false) + private Integer id; + + @Size(max = 128) + @NotNull + @Column(name = "protocol_code", nullable = false, length = 128) + private String protocolCode; + + @Size(max = 128) + @NotNull + @Column(name = "nickname", nullable = false, length = 128) + private String nickname; + +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/repository/RsuRepository.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/repository/RsuRepository.java new file mode 100644 index 000000000..97c8c3d16 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/repository/RsuRepository.java @@ -0,0 +1,12 @@ +package com.trihydro.rsuinfobridge.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import com.trihydro.rsuinfobridge.models.tables.Rsu; + +import java.util.List; + +@Repository +public interface RsuRepository extends JpaRepository { + List findByRsuOptionTimDepositIsTrue(); +} diff --git a/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/service/RsuService.java b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/service/RsuService.java new file mode 100644 index 000000000..b459ee650 --- /dev/null +++ b/services/rsu-info-bridge/src/main/java/com/trihydro/rsuinfobridge/service/RsuService.java @@ -0,0 +1,23 @@ +package com.trihydro.rsuinfobridge.service; + +import com.trihydro.rsuinfobridge.models.tables.Rsu; +import com.trihydro.rsuinfobridge.repository.RsuRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RsuService { + private final RsuRepository rsuRepository; + + public List getAll(boolean timDepositEnabledOnly) { + if (timDepositEnabledOnly) { + return rsuRepository.findByRsuOptionTimDepositIsTrue(); + } else { + return rsuRepository.findAll(); + } + + } +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/resources/application-local.yaml b/services/rsu-info-bridge/src/main/resources/application-local.yaml new file mode 100644 index 000000000..23eb281ff --- /dev/null +++ b/services/rsu-info-bridge/src/main/resources/application-local.yaml @@ -0,0 +1,7 @@ +spring: + docker: + compose: + enabled: true + file: ../../docker-compose.yml + profiles: + active: cvmanager_postgres \ No newline at end of file diff --git a/services/rsu-info-bridge/src/main/resources/application.yaml b/services/rsu-info-bridge/src/main/resources/application.yaml new file mode 100644 index 000000000..971a83306 --- /dev/null +++ b/services/rsu-info-bridge/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +spring: + application: + name: rsu-info-bridge + docker: + compose: + enabled: false + datasource: + url: ${POSTGRES_SERVER_URL:jdbc:postgresql://localhost:5432}/${POSTGRES_DB:postgres} + username: ${PG_DB_USER:postgres} + password: ${PG_DB_PASS:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true +server: + port: ${RSU_INFO_BRIDGE_PORT:16543} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplicationTests.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplicationTests.java new file mode 100644 index 000000000..e08ee2e4d --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/RsuInfoBridgeApplicationTests.java @@ -0,0 +1,18 @@ +package com.trihydro.rsuinfobridge; + +import com.trihydro.rsuinfobridge.testutil.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestcontainersConfiguration.class) +class RsuInfoBridgeApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/controller/RsuControllerTest.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/controller/RsuControllerTest.java new file mode 100644 index 000000000..018e8c757 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/controller/RsuControllerTest.java @@ -0,0 +1,299 @@ +package com.trihydro.rsuinfobridge.controller; + +import com.trihydro.rsuinfobridge.models.tables.Rsu; +import com.trihydro.rsuinfobridge.models.tables.RsuOption; +import com.trihydro.rsuinfobridge.models.tables.SnmpCredential; +import com.trihydro.rsuinfobridge.models.tables.SnmpProtocol; +import com.trihydro.rsuinfobridge.repository.RsuRepository; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class RsuControllerTest { + @MockitoBean + RsuRepository rsuRepository; + + @Autowired + MockMvc mockMvc; + + // ==================== Happy path tests ==================== + + @Test + void testGetAll_Success() throws Exception { + // Arrange + List rsus = getMockData(); + when(rsuRepository.findAll()).thenReturn(rsus); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus")); + + // Assert + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + // RSU 1 + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].ipv4Address").value("10.10.10.10")) + .andExpect(jsonPath("$[0].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[0].snmpUsername").value("myusername")) + .andExpect(jsonPath("$[0].snmpPassword").value("mypassword")) + .andExpect(jsonPath("$[0].authenticationProtocol").value("SHA")) + .andExpect(jsonPath("$[0].privacyProtocol").value("AES")) + .andExpect(jsonPath("$[0].latitude").value(39.73915)) + .andExpect(jsonPath("$[0].longitude").value(-104.9847)) + .andExpect(jsonPath("$[0].timDepositEnabled").value(true)) + // RSU 2 + .andExpect(jsonPath("$[1].id").value("2")) + .andExpect(jsonPath("$[1].ipv4Address").value("10.10.10.11")) + .andExpect(jsonPath("$[1].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[1].snmpUsername").value("myusername2")) + .andExpect(jsonPath("$[1].snmpPassword").value("mypassword2")) + .andExpect(jsonPath("$[1].authenticationProtocol").value("SHA")) + .andExpect(jsonPath("$[1].privacyProtocol").value("AES")) + .andExpect(jsonPath("$[1].latitude").value(40.0)) + .andExpect(jsonPath("$[1].longitude").value(105.0)) + .andExpect(jsonPath("$[1].timDepositEnabled").value(true)); + } + + @Test + void testGetAllWithTimDepositEnabled_Success() throws Exception { + // Arrange + List rsus = getMockData(); + when(rsuRepository.findByRsuOptionTimDepositIsTrue()).thenReturn(rsus); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus?timDepositEnabledOnly=true")); + + // Assert + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + // RSU 1 + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].ipv4Address").value("10.10.10.10")) + .andExpect(jsonPath("$[0].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[0].snmpUsername").value("myusername")) + .andExpect(jsonPath("$[0].snmpPassword").value("mypassword")) + .andExpect(jsonPath("$[0].authenticationProtocol").value("SHA")) + .andExpect(jsonPath("$[0].privacyProtocol").value("AES")) + .andExpect(jsonPath("$[0].latitude").value(39.73915)) + .andExpect(jsonPath("$[0].longitude").value(-104.9847)) + .andExpect(jsonPath("$[0].timDepositEnabled").value(true)) + // RSU 2 + .andExpect(jsonPath("$[1].id").value("2")) + .andExpect(jsonPath("$[1].ipv4Address").value("10.10.10.11")) + .andExpect(jsonPath("$[1].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[1].snmpUsername").value("myusername2")) + .andExpect(jsonPath("$[1].snmpPassword").value("mypassword2")) + .andExpect(jsonPath("$[1].authenticationProtocol").value("SHA")) + .andExpect(jsonPath("$[1].privacyProtocol").value("AES")) + .andExpect(jsonPath("$[1].latitude").value(40.0)) + .andExpect(jsonPath("$[1].longitude").value(105.0)) + .andExpect(jsonPath("$[1].timDepositEnabled").value(true)); + } + + // ==================== Unhappy path tests ==================== + + @Test + void testGetAll_EmptyList() throws Exception { + // Arrange + when(rsuRepository.findAll()).thenReturn(Collections.emptyList()); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus")); + + // Assert + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void testGetAllWithTimDepositEnabled_EmptyList() throws Exception { + // Arrange + when(rsuRepository.findByRsuOptionTimDepositIsTrue()).thenReturn(Collections.emptyList()); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus?timDepositEnabledOnly=true")); + + // Assert + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void testGetAll_RsuWithNullGeographyAndRsuOption() throws Exception { + // Arrange - RSU with null geography and rsuOption (optional relations) + // Note: snmpCredential and snmpProtocol are required by RsuDto validation, so they must be provided + SnmpProtocol snmpProtocol = new SnmpProtocol(); + snmpProtocol.setId(1); + snmpProtocol.setProtocolCode("NTCIP1218"); + + SnmpCredential snmpCredential = new SnmpCredential(); + snmpCredential.setId(1); + snmpCredential.setUsername("testuser"); + snmpCredential.setPassword("testpass"); + + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .snmpCredential(snmpCredential) + .snmpProtocol(snmpProtocol) + .geography(null) + .rsuOption(null) + .build(); + when(rsuRepository.findAll()).thenReturn(List.of(rsu)); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus")); + + // Assert + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].ipv4Address").value("10.0.0.1")) + .andExpect(jsonPath("$[0].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[0].snmpUsername").value("testuser")) + .andExpect(jsonPath("$[0].snmpPassword").value("testpass")) + .andExpect(jsonPath("$[0].authenticationProtocol").value("SHA")) + .andExpect(jsonPath("$[0].privacyProtocol").value("AES")) + // When geography is null, mapper returns 0.0 for latitude and longitude + .andExpect(jsonPath("$[0].latitude").value(0.0)) + .andExpect(jsonPath("$[0].longitude").value(0.0)) + // When rsuOption is null, mapper returns false for timDepositEnabled + .andExpect(jsonPath("$[0].timDepositEnabled").value(false)); + } + + @Test + void testGetAll_RsuWithMinimalRequiredFields() throws Exception { + // Arrange - RSU with all required fields for RsuDto validation + // id and ipv4Address are @NotBlank, so they must be provided + SnmpProtocol snmpProtocol = new SnmpProtocol(); + snmpProtocol.setId(1); + snmpProtocol.setProtocolCode("NTCIP1218"); + + SnmpCredential snmpCredential = new SnmpCredential(); + snmpCredential.setId(1); + snmpCredential.setUsername("user"); + snmpCredential.setPassword("pass"); + + GeometryFactory geometryFactory = new GeometryFactory(); + Coordinate coordinate = new Coordinate(-100.0, 40.0); + Point point = geometryFactory.createPoint(coordinate); + + RsuOption rsuOption = new RsuOption(); + rsuOption.setTimDeposit(true); + + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("192.168.1.1")) + .snmpProtocol(snmpProtocol) + .snmpCredential(snmpCredential) + .geography(point) + .rsuOption(rsuOption) + .build(); + when(rsuRepository.findAll()).thenReturn(List.of(rsu)); + + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus")); + + // Assert - all required fields are present per RsuDto validation + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].ipv4Address").value("192.168.1.1")) + .andExpect(jsonPath("$[0].snmpProtocol").value("NTCIP1218")) + .andExpect(jsonPath("$[0].snmpUsername").value("user")) + .andExpect(jsonPath("$[0].snmpPassword").value("pass")) + .andExpect(jsonPath("$[0].latitude").value(40.0)) + .andExpect(jsonPath("$[0].longitude").value(-100.0)) + .andExpect(jsonPath("$[0].timDepositEnabled").value(true)); + } + + @Test + void testPostNotAllowed() throws Exception { + // Act + ResultActions resultActions = mockMvc.perform(post("/rsus")); + + // Assert + resultActions.andExpect(status().isMethodNotAllowed()); + } + + @Test + void testGetAll_InvalidTimDepositEnabledParam() throws Exception { + // Act + ResultActions resultActions = mockMvc.perform(get("/rsus?timDepositEnabledOnly=notaboolean")); + + // Assert + resultActions.andExpect(status().isBadRequest()); + } + + // ==================== Test helpers ==================== + + List getMockData() throws UnknownHostException { + List rsus = new java.util.ArrayList<>(); + + SnmpProtocol snmpProtocol = new SnmpProtocol(); + snmpProtocol.setId(1); + snmpProtocol.setProtocolCode("NTCIP1218"); + + SnmpCredential snmpCredential1 = new SnmpCredential(); + snmpCredential1.setId(1); + snmpCredential1.setUsername("myusername"); + snmpCredential1.setPassword("mypassword"); + + SnmpCredential snmpCredential2 = new SnmpCredential(); + snmpCredential2.setId(2); + snmpCredential2.setUsername("myusername2"); + snmpCredential2.setPassword("mypassword2"); + + GeometryFactory geometryFactory = new GeometryFactory(); + Coordinate coordinate1 = new Coordinate(-104.9847, 39.73915); + Point point1 = geometryFactory.createPoint(coordinate1); + + Coordinate coordinate2 = new Coordinate(105.0, 40.0); + Point point2 = geometryFactory.createPoint(coordinate2); + + RsuOption rsuOption = new RsuOption(); + rsuOption.setTimDeposit(true); + + Rsu rsu1 = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.10.10.10")) + .snmpProtocol(snmpProtocol) + .snmpCredential(snmpCredential1) + .geography(point1) + .rsuOption(rsuOption) + .build(); + rsus.add(rsu1); + + Rsu rsu2 = Rsu.builder() + .id(2) + .ipv4Address(InetAddress.getByName("10.10.10.11")) + .snmpProtocol(snmpProtocol) + .snmpCredential(snmpCredential2) + .geography(point2) + .rsuOption(rsuOption) + .build(); + rsus.add(rsu2); + + return rsus; + } +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandlerTest.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..1590b7390 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,90 @@ +package com.trihydro.rsuinfobridge.exception; + +import com.trihydro.rsuinfobridge.service.RsuService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class GlobalExceptionHandlerTest { + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RsuService rsuService; + + @Test + void handleMethodArgumentTypeMismatchException_returnsBadRequest() throws Exception { + // Act & Assert - pass an invalid boolean value to trigger type mismatch + mockMvc.perform(get("/rsus") + .param("timDepositEnabledOnly", "notaboolean")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Type Mismatch")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").exists()); + } + + @Test + void handleHttpRequestMethodNotSupportedException_returnsMethodNotAllowed() throws Exception { + // Act & Assert - POST is not allowed on /rsus endpoint + mockMvc.perform(post("/rsus")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.title").value("Method Not Allowed")) + .andExpect(jsonPath("$.status").value(405)) + .andExpect(jsonPath("$.detail").value("HTTP method 'POST' is not supported for this endpoint")); + } + + @Test + void handleDataAccessException_returnsServiceUnavailable() throws Exception { + // Arrange + when(rsuService.getAll(anyBoolean())) + .thenThrow(new DataAccessResourceFailureException("Database connection failed")); + + // Act & Assert + mockMvc.perform(get("/rsus")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.title").value("Database Error")) + .andExpect(jsonPath("$.status").value(503)) + .andExpect(jsonPath("$.detail").value("An error occurred while accessing the database")); + } + + @Test + void handleDataAccessException_withDataIntegrityViolation_returnsServiceUnavailable() throws Exception { + // Arrange - DataIntegrityViolationException is a subclass of DataAccessException + when(rsuService.getAll(anyBoolean())) + .thenThrow(new DataIntegrityViolationException("Unique constraint violated")); + + // Act & Assert - handled by DataAccessException handler + mockMvc.perform(get("/rsus")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.title").value("Database Error")) + .andExpect(jsonPath("$.status").value(503)); + } + + @Test + void handleGenericException_returnsInternalServerError() throws Exception { + // Arrange + when(rsuService.getAll(anyBoolean())) + .thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + mockMvc.perform(get("/rsus")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.title").value("Internal Server Error")) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.detail").value("An unexpected error occurred")); + } +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapperTest.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapperTest.java new file mode 100644 index 000000000..2d71233b0 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/mapper/RsuDtoMapperTest.java @@ -0,0 +1,265 @@ +package com.trihydro.rsuinfobridge.mapper; + +import com.trihydro.rsuinfobridge.models.dtos.RsuDto; +import com.trihydro.rsuinfobridge.models.tables.Rsu; +import com.trihydro.rsuinfobridge.models.tables.RsuOption; +import com.trihydro.rsuinfobridge.models.tables.SnmpCredential; +import com.trihydro.rsuinfobridge.models.tables.SnmpProtocol; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.mapstruct.factory.Mappers; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RsuDtoMapperTest { + + private RsuDtoMapper mapper; + + @BeforeEach + void setup() { + mapper = Mappers.getMapper(RsuDtoMapper.class); + } + + @Test + void toDto_mapsAllFields() throws UnknownHostException { + // Arrange + SnmpProtocol snmpProtocol = new SnmpProtocol(); + snmpProtocol.setProtocolCode("NTCIP1218"); + + SnmpCredential snmpCredential = new SnmpCredential(); + snmpCredential.setUsername("testuser"); + snmpCredential.setPassword("testpass"); + + + GeometryFactory geometryFactory = new GeometryFactory(); + Coordinate coordinate = new Coordinate(-104.9847, 39.73915); + Point point = geometryFactory.createPoint(coordinate); + + RsuOption rsuOption = new RsuOption(); + rsuOption.setTimDeposit(true); + + Rsu rsu = Rsu.builder() + .id(42) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .snmpProtocol(snmpProtocol) + .snmpCredential(snmpCredential) + .geography(point) + .rsuOption(rsuOption) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertEquals("42", dto.getId()); + assertEquals("10.0.0.1", dto.getIpv4Address()); + assertEquals("NTCIP1218", dto.getSnmpProtocol()); + assertEquals("testuser", dto.getSnmpUsername()); + assertEquals("testpass", dto.getSnmpPassword()); + assertEquals("SHA", dto.getAuthenticationProtocol()); + assertEquals("AES", dto.getPrivacyProtocol()); + assertEquals(39.73915, dto.getLatitude()); + assertEquals(-104.9847, dto.getLongitude()); + assertTrue(dto.isTimDepositEnabled()); + } + + @Test + void toDto_returnsNullForNullInput() { + assertNull(mapper.toDto(null)); + } + + @Test + void toDto_handlesNullGeography() throws UnknownHostException { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .geography(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertEquals(0.0, dto.getLatitude()); + assertEquals(0.0, dto.getLongitude()); + } + + @Test + void toDto_handlesNullRsuOption() throws UnknownHostException { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .rsuOption(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertFalse(dto.isTimDepositEnabled()); + } + + @Test + void toDto_handlesNullIpv4Address() { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertNull(dto.getIpv4Address()); + } + + @Test + void toDto_handlesNullId() { + // Arrange + Rsu rsu = Rsu.builder() + .id(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertNull(dto.getId()); + } + + @Test + void toDto_handlesNullSnmpCredential() throws UnknownHostException { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .snmpCredential(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertNull(dto.getSnmpUsername()); + assertNull(dto.getSnmpPassword()); + } + + @Test + void toDto_handlesNullSnmpProtocol() throws UnknownHostException { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .snmpProtocol(null) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertNull(dto.getSnmpProtocol()); + } + + @Test + void toDto_timDepositFalseWhenOptionIsFalse() throws UnknownHostException { + // Arrange + RsuOption rsuOption = new RsuOption(); + rsuOption.setTimDeposit(false); + + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .rsuOption(rsuOption) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertFalse(dto.isTimDepositEnabled()); + } + + @Test + void toDto_setsConstantProtocols() throws UnknownHostException { + // Arrange + Rsu rsu = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .build(); + + // Act + RsuDto dto = mapper.toDto(rsu); + + // Assert + assertEquals(RsuDtoMapper.AUTHENTICATION_PROTOCOL, dto.getAuthenticationProtocol()); + assertEquals(RsuDtoMapper.PRIVACY_PROTOCOL, dto.getPrivacyProtocol()); + } + + @Test + void toDtoList_mapsAllElements() throws UnknownHostException { + // Arrange + SnmpProtocol snmpProtocol = new SnmpProtocol(); + snmpProtocol.setProtocolCode("NTCIP1218"); + + SnmpCredential cred1 = new SnmpCredential(); + cred1.setUsername("user1"); + cred1.setPassword("pass1"); + + SnmpCredential cred2 = new SnmpCredential(); + cred2.setUsername("user2"); + cred2.setPassword("pass2"); + + RsuOption option = new RsuOption(); + option.setTimDeposit(true); + + Rsu rsu1 = Rsu.builder() + .id(1) + .ipv4Address(InetAddress.getByName("10.0.0.1")) + .snmpProtocol(snmpProtocol) + .snmpCredential(cred1) + .rsuOption(option) + .build(); + + Rsu rsu2 = Rsu.builder() + .id(2) + .ipv4Address(InetAddress.getByName("10.0.0.2")) + .snmpProtocol(snmpProtocol) + .snmpCredential(cred2) + .rsuOption(option) + .build(); + + // Act + List dtos = mapper.toDtoList(List.of(rsu1, rsu2)); + + // Assert + assertEquals(2, dtos.size()); + assertEquals("1", dtos.get(0).getId()); + assertEquals("10.0.0.1", dtos.get(0).getIpv4Address()); + assertEquals("user1", dtos.get(0).getSnmpUsername()); + assertEquals("2", dtos.get(1).getId()); + assertEquals("10.0.0.2", dtos.get(1).getIpv4Address()); + assertEquals("user2", dtos.get(1).getSnmpUsername()); + } + + @Test + void toDtoList_returnsNullForNullInput() { + assertNull(mapper.toDtoList(null)); + } + + @Test + void toDtoList_returnsEmptyListForEmptyInput() { + List dtos = mapper.toDtoList(List.of()); + assertNotNull(dtos); + assertTrue(dtos.isEmpty()); + } +} \ No newline at end of file diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/service/RsuServiceTest.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/service/RsuServiceTest.java new file mode 100644 index 000000000..0a0f16632 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/service/RsuServiceTest.java @@ -0,0 +1,257 @@ +package com.trihydro.rsuinfobridge.service; + +import com.trihydro.rsuinfobridge.models.tables.Rsu; +import com.trihydro.rsuinfobridge.models.tables.RsuCredential; +import com.trihydro.rsuinfobridge.models.tables.RsuModel; +import com.trihydro.rsuinfobridge.models.tables.RsuOption; +import com.trihydro.rsuinfobridge.models.tables.SnmpCredential; +import com.trihydro.rsuinfobridge.models.tables.SnmpProtocol; +import com.trihydro.rsuinfobridge.testutil.repository.PingRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuCredentialRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuHealthRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuIntersectionRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuModelRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuOptionRepository; +import com.trihydro.rsuinfobridge.testutil.repository.RsuOrganizationRepository; +import com.trihydro.rsuinfobridge.testutil.repository.ScmsHealthRepository; +import com.trihydro.rsuinfobridge.repository.RsuRepository; +import com.trihydro.rsuinfobridge.testutil.repository.SnmpCredentialRepository; +import com.trihydro.rsuinfobridge.testutil.repository.SnmpMsgfwdConfigRepository; +import com.trihydro.rsuinfobridge.testutil.repository.SnmpProtocolRepository; +import com.trihydro.rsuinfobridge.testutil.TestcontainersConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for RsuService using PostGIS Testcontainer. + * Uses production schema (CVManager_CreateTables.sql) and sample data (CVManager_SampleData.sql). + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(TestcontainersConfiguration.class) +@Transactional +class RsuServiceTest { + // IDs from CVManager_SampleData.sql + static final int MODEL_ID = 1; + static final int CREDENTIAL_ID = 1; + static final int SNMP_CREDENTIAL_ID = 1; + static final int SNMP_PROTOCOL_ID = 2; // NTCIP 1218 + + // SRID 4326 for WGS84 + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326); + + @Autowired + RsuIntersectionRepository rsuIntersectionRepository; + + @Autowired + SnmpMsgfwdConfigRepository snmpMsgfwdConfigRepository; + + @Autowired + RsuOrganizationRepository rsuOrganizationRepository; + + @Autowired + RsuOptionRepository rsuOptionRepository; + + @Autowired + RsuRepository rsuRepository; + + @Autowired + RsuModelRepository rsuModelRepository; + + @Autowired + RsuCredentialRepository rsuCredentialRepository; + + @Autowired + SnmpCredentialRepository snmpCredentialRepository; + + @Autowired + SnmpProtocolRepository snmpProtocolRepository; + + @Autowired + PingRepository pingRepository; + + @Autowired + RsuHealthRepository rsuHealthRepository; + + @Autowired + ScmsHealthRepository scmsHealthRepository; + + @Autowired + private RsuService rsuService; + + @BeforeEach + void setup() { + clearRsuData(); + } + + @Test + void testGetAll_returnsAllRsus() { + // Arrange + createRsuWithOptions("10.10.10.10", 1.0, "SN001", true); + createRsuWithOptions("10.10.10.11", 2.0, "SN002", false); + + // Act & Assert + assertEquals(2, rsuService.getAll(false).size()); + } + + @Test + void testGetAll_withTimDepositEnabled_returnsOnlyEnabled() throws UnknownHostException { + // Arrange + createRsuWithOptions("10.10.10.10", 1.0, "SN001", true); + createRsuWithOptions("10.10.10.11", 2.0, "SN002", false); + + // Act + List result = rsuService.getAll(true); + + // Assert + assertEquals(1, result.size()); + assertEquals(InetAddress.getByName("10.10.10.10"), result.getFirst().getIpv4Address()); + } + + @Test + void testGetAll_withMultipleTimDepositEnabled_returnsAll() { + // Arrange + createRsuWithOptions("10.10.10.10", 1.0, "SN001", true); + createRsuWithOptions("10.10.10.11", 2.0, "SN002", true); + createRsuWithOptions("10.10.10.12", 3.0, "SN003", true); + + // Act & Assert + assertEquals(3, rsuService.getAll(true).size()); + } + + @Test + void testGetAll_returnsCorrectFields() throws UnknownHostException { + // Arrange + createRsuWithOptions("10.10.10.10", 5.5, "SN-ABC", "SCMS-XYZ", "I-70", true, false); + + // Act + List result = rsuService.getAll(false); + + // Assert + assertEquals(1, result.size()); + Rsu rsu = result.getFirst(); + assertEquals(InetAddress.getByName("10.10.10.10"), rsu.getIpv4Address()); + assertEquals(5.5, rsu.getMilepost()); + assertEquals("SN-ABC", rsu.getSerialNumber()); + assertEquals("SCMS-XYZ", rsu.getIssScmsId()); + assertEquals("I-70", rsu.getPrimaryRoute()); + assertEquals("1218", rsu.getSnmpProtocol().getProtocolCode()); + assertEquals("username", rsu.getSnmpCredential().getUsername()); + assertTrue(rsu.getRsuOption().getTimDeposit()); + assertFalse(rsu.getRsuOption().getSnmpMonitoring()); + } + + @Test + void testGetAll_emptyDatabase_returnsEmptyList() { + // Act & Assert + assertTrue(rsuService.getAll(false).isEmpty()); + assertTrue(rsuService.getAll(true).isEmpty()); + } + + @Test + void testGetAll_noneWithTimDepositEnabled_returnsEmptyList() { + // Arrange + createRsuWithOptions("10.10.10.10", 1.0, "SN001", false); + createRsuWithOptions("10.10.10.11", 2.0, "SN002", false); + + // Act & Assert + assertTrue(rsuService.getAll(true).isEmpty()); + } + + @Test + void testGetAll_timDepositFalse_returnsAll() { + // Arrange + createRsuWithOptions("10.10.10.10", 1.0, "SN001", true); + createRsuWithOptions("10.10.10.11", 2.0, "SN002", false); + + // Act & Assert + assertEquals(2, rsuService.getAll(false).size()); + } + + /** + * Clears RSU data while preserving prerequisites from sample data. + * Telemetry tables (ping, rsu_health, scms_health) keep RESTRICT FKs to + * rsus by design, so their seeded rows must be deleted before the RSUs. + */ + void clearRsuData() { + pingRepository.deleteAllInBatch(); + rsuHealthRepository.deleteAllInBatch(); + scmsHealthRepository.deleteAllInBatch(); + rsuIntersectionRepository.deleteAll(); + snmpMsgfwdConfigRepository.deleteAll(); + rsuOrganizationRepository.deleteAll(); + rsuOptionRepository.deleteAll(); + rsuRepository.deleteAll(); + } + + /** + * Creates an RSU with options using sample data prerequisites. + */ + void createRsuWithOptions(String ipv4Address, double milepost, String serialNumber, boolean timDeposit) { + createRsuWithOptions(ipv4Address, milepost, serialNumber, + "SCMS-" + serialNumber, "Route-" + serialNumber, timDeposit, false); + } + + /** + * Creates an RSU with options using full control over fields. + */ + void createRsuWithOptions(String ipv4Address, double milepost, String serialNumber, + String issScmsId, String primaryRoute, + boolean timDeposit, boolean snmpMonitoring) { + try { + // Fetch reference entities from sample data + RsuModel model = rsuModelRepository.getReferenceById(MODEL_ID); + RsuCredential credential = rsuCredentialRepository.getReferenceById(CREDENTIAL_ID); + SnmpCredential snmpCredential = snmpCredentialRepository.getReferenceById(SNMP_CREDENTIAL_ID); + SnmpProtocol snmpProtocol = snmpProtocolRepository.getReferenceById(SNMP_PROTOCOL_ID); + + // Create geography point at origin (0, 0) + Point geography = GEOMETRY_FACTORY.createPoint(new Coordinate(0, 0)); + + // Build and save RSU + Rsu rsu = Rsu.builder() + .geography(geography) + .milepost(milepost) + .ipv4Address(InetAddress.getByName(ipv4Address)) + .serialNumber(serialNumber) + .issScmsId(issScmsId) + .primaryRoute(primaryRoute) + .model(model) + .credential(credential) + .snmpCredential(snmpCredential) + .snmpProtocol(snmpProtocol) + .build(); + + Rsu savedRsu = rsuRepository.save(rsu); + + // Create and save RSU options + RsuOption rsuOption = new RsuOption(); + rsuOption.setRsu(savedRsu); + rsuOption.setTimDeposit(timDeposit); + rsuOption.setSnmpMonitoring(snmpMonitoring); + + RsuOption savedRsuOption = rsuOptionRepository.save(rsuOption); + + // Link the option back to the RSU for bidirectional relationship + savedRsu.setRsuOption(savedRsuOption); + rsuRepository.save(savedRsu); + } catch (UnknownHostException e) { + throw new RuntimeException("Invalid IP address: " + ipv4Address, e); + } + } +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/TestcontainersConfiguration.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/TestcontainersConfiguration.java new file mode 100644 index 000000000..4dd7af6e0 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/TestcontainersConfiguration.java @@ -0,0 +1,37 @@ +package com.trihydro.rsuinfobridge.testutil; + +import javax.sql.DataSource; + +import org.flywaydb.core.Flyway; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + private static final DockerImageName POSTGIS_IMAGE = DockerImageName + .parse("postgis/postgis:15-3.4-alpine") + .asCompatibleSubstituteFor("postgres"); + + @Bean + @ServiceConnection + @SuppressWarnings("resource") + public PostgreSQLContainer postgisContainer() { + return new PostgreSQLContainer<>(POSTGIS_IMAGE); + } + + // Spring Boot 4 removed FlywayAutoConfiguration so wire it explicitly. + @Bean + public Flyway flyway(DataSource dataSource) { + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .mixed(true) + .load(); + flyway.migrate(); + return flyway; + } +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/Ping.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/Ping.java new file mode 100644 index 000000000..c75c14372 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/Ping.java @@ -0,0 +1,28 @@ +package com.trihydro.rsuinfobridge.testutil.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Minimal test-only mapping of the ping telemetry table. The bridge has no + * production entity for this table; this exists so tests can clear telemetry + * rows (RESTRICT FK to rsus) through a JPA repository before deleting RSUs. + * Cleanup-only: not all columns are mapped, so it must not be used for inserts + * and is incompatible with hibernate ddl-auto validate/update. + */ +@Getter +@Setter +@Entity +@Table(name = "ping") +public class Ping { + @Id + @Column(name = "ping_id", nullable = false) + private Integer id; + + @Column(name = "rsu_id", nullable = false) + private Integer rsuId; +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/RsuHealth.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/RsuHealth.java new file mode 100644 index 000000000..96c8519e2 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/RsuHealth.java @@ -0,0 +1,28 @@ +package com.trihydro.rsuinfobridge.testutil.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Minimal test-only mapping of the rsu_health telemetry table. The bridge has + * no production entity for this table; this exists so tests can clear telemetry + * rows (RESTRICT FK to rsus) through a JPA repository before deleting RSUs. + * Cleanup-only: not all columns are mapped, so it must not be used for inserts + * and is incompatible with hibernate ddl-auto validate/update. + */ +@Getter +@Setter +@Entity +@Table(name = "rsu_health") +public class RsuHealth { + @Id + @Column(name = "rsu_health_id", nullable = false) + private Integer id; + + @Column(name = "rsu_id", nullable = false) + private Integer rsuId; +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/ScmsHealth.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/ScmsHealth.java new file mode 100644 index 000000000..92a732c23 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/models/ScmsHealth.java @@ -0,0 +1,28 @@ +package com.trihydro.rsuinfobridge.testutil.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Minimal test-only mapping of the scms_health telemetry table. The bridge has + * no production entity for this table; this exists so tests can clear telemetry + * rows (RESTRICT FK to rsus) through a JPA repository before deleting RSUs. + * Cleanup-only: not all columns are mapped, so it must not be used for inserts + * and is incompatible with hibernate ddl-auto validate/update. + */ +@Getter +@Setter +@Entity +@Table(name = "scms_health") +public class ScmsHealth { + @Id + @Column(name = "scms_health_id", nullable = false) + private Integer id; + + @Column(name = "rsu_id", nullable = false) + private Integer rsuId; +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/PingRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/PingRepository.java new file mode 100644 index 000000000..5cc029468 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/PingRepository.java @@ -0,0 +1,10 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.testutil.models.Ping; + +@Repository +public interface PingRepository extends JpaRepository { +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuCredentialRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuCredentialRepository.java new file mode 100644 index 000000000..3d97d7a39 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuCredentialRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.RsuCredential; + +@Repository +public interface RsuCredentialRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuHealthRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuHealthRepository.java new file mode 100644 index 000000000..08ce1ede3 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuHealthRepository.java @@ -0,0 +1,10 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.testutil.models.RsuHealth; + +@Repository +public interface RsuHealthRepository extends JpaRepository { +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuIntersectionRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuIntersectionRepository.java new file mode 100644 index 000000000..6a68bb062 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuIntersectionRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.RsuIntersection; + +@Repository +public interface RsuIntersectionRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuModelRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuModelRepository.java new file mode 100644 index 000000000..72714ce7c --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuModelRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.RsuModel; + +@Repository +public interface RsuModelRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOptionRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOptionRepository.java new file mode 100644 index 000000000..f42d61a0d --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOptionRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.RsuOption; + +@Repository +public interface RsuOptionRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOrganizationRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOrganizationRepository.java new file mode 100644 index 000000000..6362d4d02 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/RsuOrganizationRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.RsuOrganization; + +@Repository +public interface RsuOrganizationRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/ScmsHealthRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/ScmsHealthRepository.java new file mode 100644 index 000000000..8fed74e84 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/ScmsHealthRepository.java @@ -0,0 +1,10 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.testutil.models.ScmsHealth; + +@Repository +public interface ScmsHealthRepository extends JpaRepository { +} diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpCredentialRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpCredentialRepository.java new file mode 100644 index 000000000..b578135df --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpCredentialRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.SnmpCredential; + +@Repository +public interface SnmpCredentialRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpMsgfwdConfigRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpMsgfwdConfigRepository.java new file mode 100644 index 000000000..e8aebb1e3 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpMsgfwdConfigRepository.java @@ -0,0 +1,12 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.SnmpMsgfwdConfig; +import com.trihydro.rsuinfobridge.models.tables.SnmpMsgfwdConfigId; + +@Repository +public interface SnmpMsgfwdConfigRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpProtocolRepository.java b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpProtocolRepository.java new file mode 100644 index 000000000..dae666ee0 --- /dev/null +++ b/services/rsu-info-bridge/src/test/java/com/trihydro/rsuinfobridge/testutil/repository/SnmpProtocolRepository.java @@ -0,0 +1,11 @@ +package com.trihydro.rsuinfobridge.testutil.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.trihydro.rsuinfobridge.models.tables.SnmpProtocol; + +@Repository +public interface SnmpProtocolRepository extends JpaRepository { +} + diff --git a/services/rsu-info-bridge/src/test/resources/application-test.yaml b/services/rsu-info-bridge/src/test/resources/application-test.yaml new file mode 100644 index 000000000..22443f6fb --- /dev/null +++ b/services/rsu-info-bridge/src/test/resources/application-test.yaml @@ -0,0 +1,11 @@ +spring: + application: + name: rsu-info-bridge-test + jpa: + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true diff --git a/webapp/.dockerignore b/webapp/.dockerignore index 653860b4e..539e33ae1 100644 --- a/webapp/.dockerignore +++ b/webapp/.dockerignore @@ -3,5 +3,5 @@ Dockerfile .gitignore README.md -sample.env.local +sample.env.development.local .env.local \ No newline at end of file diff --git a/webapp/.env.development b/webapp/.env.development deleted file mode 100644 index 9df5b87ba..000000000 --- a/webapp/.env.development +++ /dev/null @@ -1,40 +0,0 @@ -# used to determine where to install build -BUILD_PATH='./build' - -# mapbox access token -REACT_APP_MAPBOX_TOKEN= - -# used to swap endpoints for various environments or leave blank -REACT_APP_ENV="dev" - -# base url for api -REACT_APP_GATEWAY_BASE_URL="http://cvmanager.local.com:8081" - -# base url or IP for keycloak -REACT_APP_KEYCLOAK_URL="http://cvmanager.auth.com:8084/" -REACT_APP_KEYCLOAK_REALM="cvmanager" -REACT_APP_KEYCLOAK_CLIENT_ID=cvmanager-gui - -REACT_APP_COUNT_MESSAGE_TYPES='BSM,SSM,SPAT,SRM,MAP' -REACT_APP_DOT_NAME="CDOT" - -REACT_APP_CVIZ_API_SERVER_URL='http://localhost:8089' -REACT_APP_CVIZ_API_WS_URL='ws://localhost:8089' - -# initial mapbox view -REACT_APP_MAPBOX_INIT_LATITUDE=39.7392 -REACT_APP_MAPBOX_INIT_LONGITUDE=-104.9903 -REACT_APP_MAPBOX_INIT_ZOOM=10 - -# 'true' to enable a feature and 'false' to disable it: -REACT_APP_ENABLE_RSU_FEATURES='true' -REACT_APP_ENABLE_INTERSECTION_FEATURES='true' -REACT_APP_ENABLE_WZDX_FEATURES='true' -REACT_APP_ENABLE_HAAS_FEATURES='true' - -# Webapp themes: dark -# base theme is used by default, dark theme is used if browser is set to dark mode -REACT_APP_WEBAPP_THEME_LIGHT="dark" -# if not set, defaults to 'light' -REACT_APP_WEBAPP_THEME_DARK="dark" -# if not set, defaults to 'dark' \ No newline at end of file diff --git a/webapp/.gitignore b/webapp/.gitignore index 680525e13..bd342404e 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -1,3 +1,7 @@ +# TypeScript build info +*.tsbuildinfo +tsconfig.tsbuildinfo + # dependencies /node_modules /.pnp diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 5c2054e98..a6085e1f9 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,5 +1,5 @@ # Build React App With Node -FROM node:18-alpine AS builder +FROM node:22-alpine AS builder # Set working directory WORKDIR /app @@ -17,64 +17,58 @@ COPY . . ENV BUILD_PATH="./build" ARG API_URI -ENV REACT_APP_GATEWAY_BASE_URL=$API_URI +ENV VITE_GATEWAY_BASE_URL=$API_URI ARG MAPBOX_TOKEN -ENV REACT_APP_MAPBOX_TOKEN=$MAPBOX_TOKEN +ENV VITE_MAPBOX_TOKEN=$MAPBOX_TOKEN ARG KEYCLOAK_HOST_URL -ENV REACT_APP_KEYCLOAK_URL=$KEYCLOAK_HOST_URL +ENV VITE_KEYCLOAK_URL=$KEYCLOAK_HOST_URL ARG KEYCLOAK_REALM -ENV REACT_APP_KEYCLOAK_REALM=$KEYCLOAK_REALM +ENV VITE_KEYCLOAK_REALM=$KEYCLOAK_REALM ARG KEYCLOAK_CLIENT_ID -ENV REACT_APP_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID - -ARG COUNT_MESSAGE_TYPES -ENV REACT_APP_COUNT_MESSAGE_TYPES=$COUNT_MESSAGE_TYPES +ENV VITE_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID ARG VIEWER_MESSAGE_TYPES -ENV REACT_APP_VIEWER_MESSAGE_TYPES=$VIEWER_MESSAGE_TYPES +ENV VITE_VIEWER_MESSAGE_TYPES=$VIEWER_MESSAGE_TYPES ARG DOT_NAME -ENV REACT_APP_DOT_NAME=$DOT_NAME +ENV VITE_DOT_NAME=$DOT_NAME ARG MAPBOX_INIT_LATITUDE -ENV REACT_APP_MAPBOX_INIT_LATITUDE=$MAPBOX_INIT_LATITUDE +ENV VITE_MAPBOX_INIT_LATITUDE=$MAPBOX_INIT_LATITUDE ARG MAPBOX_INIT_LONGITUDE -ENV REACT_APP_MAPBOX_INIT_LONGITUDE=$MAPBOX_INIT_LONGITUDE +ENV VITE_MAPBOX_INIT_LONGITUDE=$MAPBOX_INIT_LONGITUDE ARG MAPBOX_INIT_ZOOM -ENV REACT_APP_MAPBOX_INIT_ZOOM=$MAPBOX_INIT_ZOOM +ENV VITE_MAPBOX_INIT_ZOOM=$MAPBOX_INIT_ZOOM ARG CVIZ_API_SERVER_URL -ENV REACT_APP_CVIZ_API_SERVER_URL=$CVIZ_API_SERVER_URL +ENV VITE_CVIZ_API_SERVER_URL=$CVIZ_API_SERVER_URL ARG CVIZ_API_WS_URL -ENV REACT_APP_CVIZ_API_WS_URL=$CVIZ_API_WS_URL +ENV VITE_CVIZ_API_WS_URL=$CVIZ_API_WS_URL ARG ENABLE_RSU_FEATURES -ENV REACT_APP_ENABLE_RSU_FEATURES=$ENABLE_RSU_FEATURES +ENV VITE_ENABLE_RSU_FEATURES=$ENABLE_RSU_FEATURES ARG ENABLE_INTERSECTION_FEATURES -ENV REACT_APP_ENABLE_INTERSECTION_FEATURES=$ENABLE_INTERSECTION_FEATURES +ENV VITE_ENABLE_INTERSECTION_FEATURES=$ENABLE_INTERSECTION_FEATURES ARG ENABLE_WZDX_FEATURES -ENV REACT_APP_ENABLE_WZDX_FEATURES=$ENABLE_WZDX_FEATURES - -ARG ENABLE_MOOVE_AI_FEATURES -ENV REACT_APP_ENABLE_MOOVE_AI_FEATURES=$ENABLE_MOOVE_AI_FEATURES +ENV VITE_ENABLE_WZDX_FEATURES=$ENABLE_WZDX_FEATURES ARG WEBAPP_THEME_LIGHT -ENV REACT_APP_WEBAPP_THEME_LIGHT=$WEBAPP_THEME_LIGHT +ENV VITE_WEBAPP_THEME_LIGHT=$WEBAPP_THEME_LIGHT ARG WEBAPP_THEME_DARK -ENV REACT_APP_WEBAPP_THEME_DARK=$WEBAPP_THEME_DARK +ENV VITE_WEBAPP_THEME_DARK=$WEBAPP_THEME_DARK ARG ENABLE_HAAS_FEATURES -ENV REACT_APP_ENABLE_HAAS_FEATURES=$ENABLE_HAAS_FEATURES +ENV VITE_ENABLE_HAAS_FEATURES=$ENABLE_HAAS_FEATURES # Build the React app RUN npm run build diff --git a/webapp/README.md b/webapp/README.md index d36779d43..438c6da53 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -2,12 +2,13 @@ This is a web application that is made with React JS that is a front-end for interfacing with CDOT RSUs. The code for the application is being hosted in a public repository to allow other CV projects to utilize. Automated deployment of the application is not included within the project. -## Required Tools For Running The CV Manager React Webapp +## Requirements - Mapbox Access Token - Create account at https://www.mapbox.com/ - An access token will be provided on the account page once the account has been created - - Put the access key in the "sample.env.local" file for REACT_APP_MAPBOX_TOKEN and rename the file ".env.development.local" + - For more instructions, see the main README [Creating a Mapbox Token](../README.md#creating-a-mapbox-token) + - Set the access key in the "sample.env.development.local" file as the VITE_MAPBOX_TOKEN - npm - Download instructions: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm - Nodejs @@ -16,15 +17,18 @@ This is a web application that is made with React JS that is a front-end for int - Repository: https://github.com/usdot-jpo-ode/jpo-conflictmonitor - Follow the instructions in the README to build/deploy the tool -## Building The Application +## Running The Application -1. Setup environment variables - - The project includes a sample environment variable file in [sample.env.local](sample.env.local) for running the application locally. - - Use the dev, test and prod environment var files for deploying in different environments. The commented out environment variables are required. -2. Build the application - - Building for local: `npm run build` - - Building for specific environment: `npm run build:dev` - - Build for all environments: `npm run build:all` +1. Copy the `sample.env.development.local` file to a new file named `.env.local`. Make sure this new file is located in this directory (webapp), not root +2. Edit the `.env.local` file to set the required environment variables. At a minimum, you will need to set the following variables: + - `DOCKER_HOST_IP`: The IP address of your Docker host. This can be found through linux/wsl through the command "ifconfig", or "localhost" if using Docker Desktop on Windows or Linux (not mac). + - `VITE_MAPBOX_TOKEN`: Any valid mapbox token. Please see the main README [Creating a Mapbox Token](../README.md#creating-a-mapbox-token) for instructions on how to create and account/generate a new token +3. Build the application + +```sh +npm install +npm start +``` ## Theming @@ -35,8 +39,8 @@ This application uses MUI themes to set the color scheme. There are currently 2 - 'light': light theme (not currently maintained) - 'dark': dark theme To set the theme of the UI, simply set the following environment variables: -- WEBAPP_THEME_LIGHT: set name of theme to use when browser is in light mode -- WEBAPP_THEME_DARK: set name of theme to use when browser is in dark mode +- VITE_WEBAPP_THEME_LIGHT: set name of theme to use when browser is in light mode +- VITE_WEBAPP_THEME_DARK: set name of theme to use when browser is in dark mode To make a custom theme, create another theme definition in ./src/styles/index.ts. Instructions for modifying an MUI theme can be found at [MUI Theming](https://mui.com/material-ui/customization/theming/) @@ -44,8 +48,8 @@ To make a custom theme, create another theme definition in ./src/styles/index.ts The CVManager has an icon in the upper left. This icon is configurable through environment variables: -- WEBAPP_LOGO_PNG_ROOT_FILE_PATH_LIGHT: Set the path to a .png icon to use when displaying a light theme (from MUI theme.palette.mode) -- WEBAPP_LOGO_PNG_ROOT_FILE_PATH_DARK: Set the path to a .png icon to use when displaying a dark theme (from MUI theme.palette.mode) +- VITE_WEBAPP_LOGO_PNG_ROOT_FILE_PATH_LIGHT: Set the path to a .png icon to use when displaying a light theme (from MUI theme.palette.mode) +- VITE_WEBAPP_LOGO_PNG_ROOT_FILE_PATH_DARK: Set the path to a .png icon to use when displaying a dark theme (from MUI theme.palette.mode) ## Editing Mapbox Style @@ -63,7 +67,7 @@ The CVManager has an icon in the upper left. This icon is configurable through e 3. Download zip 4. Paste the new "style.json" inside the zip in 'cdot-web-app/style/' -To use a new style, the style URL from Mapbox Studio must be pasted in the ".env.local" file for REACT_APP_MAPBOX_STYLE. +To use a new style, the style URL from Mapbox Studio must be pasted in the ".env.local" file for VITE_MAPBOX_STYLE. ## Google Cloud Storage (GCS) Web Hosting: Hosting the CV Manager React Webapp @@ -129,19 +133,14 @@ Re-factoring RSU manager to utilize Redux Toolkit for state management - WZDx data - References - Map.js - read and load WZDx data -5. mooveaiSlice - - Moove.AI data - - References - - Map.js - read and load Moove.AI data ## Feature Flags This application has the ability to disable certain features based on environment variables. For each of these variables, the feature will be enabled if the variable is anything but 'false'. These features include: -- ENABLE_RSU_FEATURES: if 'false', disable all RSU-specific features, including map, RSU data, RSU configuration, and RSU organization linking. -- ENABLE_INTERSECTION_FEATURES: if 'false', disable all intersection-specific features, including intersection map, intersection dashboard, and intersection admin pages. -- ENABLE_WZDX_FEATURES: if 'false', disable all wzdx-specific features, including WZDx data on the main map. -- ENABLE_MOOVE_AI_FEATURES: if 'false', disable all Moove.AI-specific features. This includes the Moove.AI visualization layer on the map. +- VITE_ENABLE_RSU_FEATURES: if 'false', disable all RSU-specific features, including map, RSU data, RSU configuration, and RSU organization linking. +- VITE_ENABLE_INTERSECTION_FEATURES: if 'false', disable all intersection-specific features, including intersection map, intersection dashboard, and intersection admin pages. +- VITE_ENABLE_WZDX_FEATURES: if 'false', disable all wzdx-specific features, including WZDx data on the main map. These variables apply to API calls, by returning empty data if the feature is disabled. To aid in applying these features visually, components were created to handle the conditional rendering of these features. These components are: @@ -162,7 +161,15 @@ The USDOT [jpo-conflictvisualizer](https://github.com/usdot-jpo-ode/jpo-conflict The conflictvisualizer API is now integrated into the cvmanager as the intersection-api. -These changes were tested running locally in docker. These changes require an intersection-api to be running, and to be connected to the cvmanager keycloak server (and cvmanager keycloak realm). This API also requires the jpo-conflictmonitor and jpo-geojsonconverter to be running, so that there is data available. Once the jpo-conflictmonitor, jpo-geojsonconverter, and jpo-ode, then a jpo-conflictvisualizer api should be deployed, which should be modified to authenticate with the cvmanager keycloak realm (see the conflictvisualizer-map-page branch), and the port should be specified in the environment file (REACT_APP_CVIZ_API_SERVER_URL). Once all of these components are deployed, then the cvmanager webapp can be run! +These changes were tested running locally in docker. These changes require an intersection-api to be running, and to be connected to the cvmanager keycloak server (and cvmanager keycloak realm). This API also requires the jpo-conflictmonitor and jpo-geojsonconverter to be running, so that there is data available. Once the jpo-conflictmonitor, jpo-geojsonconverter, and jpo-ode, then a jpo-conflictvisualizer api should be deployed, which should be modified to authenticate with the cvmanager keycloak realm (see the conflictvisualizer-map-page branch), and the port should be specified in the environment file (VITE_CVIZ_API_SERVER_URL). Once all of these components are deployed, then the cvmanager webapp can be run! + +## Typescript Type Checking + +To run typescript type checking, you can use the following command: + +```sh +npm run typecheck +``` ## Unit Testing diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 000000000..d89061ebc --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,16 @@ + + + + + + + + + CV Manager + + + +
+ + + diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 15e465ebc..e87aa99a3 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -11,7 +11,7 @@ "@emotion/react": "^11.9.0", "@emotion/styled": "^11.13.0", "@hookform/error-message": "^2.0.1", - "@material-table/core": "^6.1.4", + "@material-table/core": "6.2.11", "@mui/icons-material": "^6.1.4", "@mui/material": "^6.1.4", "@mui/styles": "^6.1.4", @@ -27,15 +27,15 @@ "date-fns-tz": "^3.2.0", "dayjs": "^1.11.7", "env-cmd": "^10.1.0", - "eslint-config-react-app": "^7.0.1", "file-saver": "^2.0.5", "formik": "^2.4.6", "geojson": "^0.5.0", "html-to-image": "^1.11.11", "jest-fetch-mock": "^3.0.3", "js-sha256": "^0.11.0", - "jspdf": "^2.5.2", + "jspdf": "^4.1.0", "jszip": "^3.10.1", + "jwt-decode": "^4.0.0", "keycloak-js": "26.0.5", "luxon": "^3.5.0", "mapbox-gl": "^2.9.1", @@ -50,7 +50,6 @@ "react-perfect-scrollbar": "^1.5.8", "react-redux": "^8.0.5", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "react-secure-storage": "^1.3.2", "react-spinners": "^0.11.0", "react-to-print": "^3.0.1", @@ -62,60 +61,114 @@ "yup": "^1.4.0" }, "devDependencies": { - "@eslint/js": "^9.28.0", + "@eslint/js": "^9.39.2", "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^30.0.0", + "@types/node": "^22.15.19", "@types/react": "^17.0.0", - "@types/react-dom": "^18.2.7", - "eslint": "^8.57.1", - "jest-canvas-mock": "^2.5.2", - "typescript": "^4.9.5", - "typescript-eslint": "^8.34.0" - } + "@types/react-dom": "^17.0.0", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^3.0.0", + "eslint": "^9.39.2", + "jsdom": "^27.4.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^7.0.0", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^3.0.0", + "vitest-canvas-mock": "^0.3.3" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6.0.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", - "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "20 || >=22" } }, - "node_modules/@ampproject/remapping": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", - "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.1.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6.0.0" + "node": "20 || >=22" } }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -124,28 +177,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", - "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz", - "integrity": "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.3", - "@babel/parser": "^7.27.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.3", - "@babel/types": "^7.27.3", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -163,94 +220,34 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/eslint-parser": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.23.3.tgz", - "integrity": "sha512-9bTuNlyx7oSstodm1cR1bECj4fkiknsDa1YniISkJemMY3DGhJNYBECbe6QD/q54mp2J8VO66jW3/7uP//iFCw==", - "dependencies": { - "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", - "eslint-visitor-keys": "^2.1.0", - "semver": "^6.3.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || >=14.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0", - "eslint": "^7.5.0 || ^8.0.0" - } - }, - "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "engines": { - "node": ">=10" - } - }, - "node_modules/@babel/eslint-parser/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -260,113 +257,38 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -375,65 +297,12 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -442,14 +311,16 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -458,41 +329,33 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz", - "integrity": "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz", - "integrity": "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -501,25 +364,12 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -527,13 +377,15 @@ "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -541,1941 +393,2026 @@ "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-proposal-class-properties": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", - "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.23.7.tgz", - "integrity": "sha512-b1s5JyeMvqj7d9m9KhJNHKc18gEJiSyVzVX3bwbiPalQBQpuvfPh6lA9F7Kk/dWH0TIiXRpB9yicwijY6buPng==", + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.23.7", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-decorators": "^7.23.3" + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", - "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-numeric-separator": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", - "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" - }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-proposal-optional-chaining": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", - "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=18" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@babel/plugin-proposal-private-methods": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", - "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=18" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@date-io/core": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", + "integrity": "sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==", + "license": "MIT" }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.23.3.tgz", - "integrity": "sha512-cf7Niq4/+/juY67E0PbgH0TDhLQ5J7zS8C/Q5FFx+DWyrRa9sUQdTXkjqKu8zGvuqr7vw1muKiukseihU+PJDA==", + "node_modules/@date-io/dayjs": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.17.0.tgz", + "integrity": "sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" + "@date-io/core": "^2.17.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.23.3.tgz", - "integrity": "sha512-YZiAIpkJAwQXBJLIQbRFayR5c+gJ35Vcz3bg954k7cd73zqjvhacJuL9RbrzPz8qPmZdgqP6EUKwy0PCNhaaPA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" + "dayjs": "^1.8.17" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "peerDependenciesMeta": { + "dayjs": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "node_modules/@date-io/luxon": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.17.0.tgz", + "integrity": "sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@date-io/core": "^2.17.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "luxon": "^1.21.3 || ^2.x || ^3.x" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "peerDependenciesMeta": { + "luxon": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@date-io/moment": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.17.0.tgz", + "integrity": "sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@date-io/core": "^2.17.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "moment": "^2.24.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "peerDependenciesMeta": { + "moment": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", - "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@emotion/core": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-11.0.0.tgz", + "integrity": "sha512-w4sE3AmHmyG6RDKf6mIbtHpgJUSJ2uGvPQb8VXFL7hFjMPibE8IiehG8cMX3Ztm4svfCQV6KqusQbeIOkurBcA==", + "license": "MIT" }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emotion/memoize": "^0.9.0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.23.3.tgz", - "integrity": "sha512-9EiNjVJOMwCO+43TqoTrgQ8jMwcAd0sWyXi9RPfIsLTj4R2MADDDQXELhffaUx/uJv2AYcxBgPwH6j4TIA4ytQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": ">=16.8.0" } }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", - "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz", - "integrity": "sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" - }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-classes/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" - }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.3.tgz", - "integrity": "sha512-s4Jrok82JpiaIprtY2nHsYmrThKvvwgHwjgd7UMiYhZaN0asdXNLr0y+NjTfkA7SyQE5i2Fb7eawUOZmLvyqOA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.23.3.tgz", - "integrity": "sha512-26/pQTf9nQSNVJCrLB1IkHUKyPxR+lMrH2QDPG89+Znu9rAMbtrybdbWeE9bb7gzjmE5iXHEY+e0HUwM6Co93Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-flow": "^7.23.3" - }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=6.9.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=6.9.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.3.tgz", - "integrity": "sha512-7ZZtznF9g4l2JCImCo5LNKFHB5eXnN39lLtLY5Tg+VkR0jwOt7TBciMckuiQIOIW7L5tkQOCh3bVGYeXgMx52Q==", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.3", - "@babel/plugin-transform-parameters": "^7.27.1" - }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@exodus/bytes": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.10.0.tgz", + "integrity": "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "node_modules/@hello-pangea/dnd": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-16.6.0.tgz", + "integrity": "sha512-vfZ4GydqbtUPXSLfAvKvXQ6xwRzIjUSjVU0Sx+70VOhc2xx6CdmJXJ8YhH70RpbTUGjxctslQTHul9sIOxCfFQ==", + "license": "Apache-2.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/runtime": "^7.24.1", + "css-box-model": "^1.2.1", + "memoize-one": "^6.0.0", + "raf-schd": "^4.0.3", + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "use-memo-one": "^1.1.3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" } }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, + "node_modules/@hookform/error-message": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hookform/error-message/-/error-message-2.0.1.tgz", + "integrity": "sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg==", + "license": "MIT", "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "react-hook-form": "^7.0.0" } }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18.18.0" } }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18.18.0" } }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" + "node": ">=12.22" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.23.3.tgz", - "integrity": "sha512-zP0QKq/p6O42OL94udMgSfKXyse4RyJ0JqbQ34zDAONWjyrEsghYEyTSK5FIpmXmCpB55SHokL1cRRKHv8L2Qw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" + "node": ">=18.18" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz", - "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=12" } }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.21.0.tgz", - "integrity": "sha512-6OAWljMvQrZjR2DaNhVfRz6dkCAVV+ymcLUmaf8bccGOHn2v5rHJK3tTpij0BuhdYWP4LLaqj5lwcdlpAAPuvg==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.18.6", - "@babel/helper-module-imports": "^7.18.6", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-jsx": "^7.18.6", - "@babel/types": "^7.21.0" - }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz", - "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.18.6" - }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz", - "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==", + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", - "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=8" } }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.7.tgz", - "integrity": "sha512-fa0hnfmiXc9fq/weK34MUV0drz2pOL/vfKWvN7Qw127hiUPabFCUMgAbYWcchRzMJit4o5ARsK/s+5h0249pLw==", - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", - "semver": "^6.3.1" - }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@jest/get-type": "30.1.0" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@types/node": "*", + "jest-regex-util": "30.0.1" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" + "@sinclair/typebox": "^0.34.0" }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.23.6.tgz", - "integrity": "sha512-6cBG5mBvUu4VUD04OHKnYzbuHNP8huDsD3EDqqpIpsswTDoqHCjLoHb6+QgsV1WsT2nipRqCPgxD3LXnEO7XfA==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-typescript": "^7.23.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "get-stream": "^6.0.1", + "minimist": "^1.2.6" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "bin": { + "geojson-rewind": "geojson-rewind" } }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">= 0.6" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz", + "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==", + "license": "BSD-3-Clause" }, - "node_modules/@babel/preset-env": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz", - "integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.27.1", - "@babel/plugin-transform-async-to-generator": "^7.27.1", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.27.1", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.27.1", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.1", - "@babel/plugin-transform-dotall-regex": "^7.27.1", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-exponentiation-operator": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.27.1", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.27.2", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.27.1", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + "@mapbox/point-geometry": "~0.1.0" } }, - "node_modules/@babel/preset-react": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", - "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.18.6", - "@babel/helper-validator-option": "^7.18.6", - "@babel/plugin-transform-react-display-name": "^7.18.6", - "@babel/plugin-transform-react-jsx": "^7.18.6", - "@babel/plugin-transform-react-jsx-development": "^7.18.6", - "@babel/plugin-transform-react-pure-annotations": "^7.18.6" - }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=6.0.0" } }, - "node_modules/@babel/preset-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.23.3.tgz", - "integrity": "sha512-17oIGVlqz6CchO9RFYn5U6ZpWRZIngayYCtrPRSgANSwC2V1Jb+iP74nVxzzXJte8b8BYxrL1yY96xfhTBrNNQ==", + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-typescript": "^7.23.3" - }, - "engines": { - "node": ">=6.9.0" + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@babel/runtime": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz", - "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==", + "node_modules/@material-table/core": { + "version": "6.2.11", + "resolved": "https://registry.npmjs.org/@material-table/core/-/core-6.2.11.tgz", + "integrity": "sha512-ErvpDT/tucUsGwb+vUsHIlHX/ec+aIxwPSSkgz5Ie4SqEKurOpE5R8vCXTFWJ+kV3fgnRtg9jFXfI6dL67N99g==", "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.14.0" + "@babel/runtime": "^7.19.0", + "@date-io/core": "^2.16.0", + "@date-io/date-fns": "^2.16.0", + "@emotion/core": "^11.0.0", + "@emotion/react": "^11.10.4", + "@emotion/styled": "^11.10.4", + "@hello-pangea/dnd": "^16.0.0", + "@mui/icons-material": ">=5.10.6", + "@mui/material": ">=5.11.12", + "@mui/x-date-pickers": "^5.0.3", + "classnames": "^2.3.2", + "date-fns": "^2.29.3", + "debounce": "^1.2.1", + "deep-eql": "^4.1.1", + "deepmerge": "^4.2.2", + "prop-types": "^15.8.1", + "react-double-scrollbar": "0.0.15", + "uuid": "^9.0.0", + "zustand": "^4.3.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@mui/system": ">=5.10.7", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "node_modules/@material-table/core/node_modules/@date-io/date-fns": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.17.0.tgz", + "integrity": "sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@date-io/core": "^2.17.0" }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz", - "integrity": "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ==", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "peerDependencies": { + "date-fns": "^2.0.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependenciesMeta": { + "date-fns": { + "optional": true + } } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/@material-table/core/node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" - }, - "node_modules/@csstools/normalize.css": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", - "integrity": "sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ==" - }, - "node_modules/@csstools/postcss-cascade-layers": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", - "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", - "dependencies": { - "@csstools/selector-specificity": "^2.0.2", - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://opencollective.com/mui-org" } }, - "node_modules/@csstools/postcss-color-function": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", - "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "node_modules/@material-table/core/node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" - } - }, - "node_modules/@csstools/postcss-font-format-keywords": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", - "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, - "peerDependencies": { - "postcss": "^8.2" + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-hwb-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", - "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "node_modules/@material-table/core/node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-ic-unit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", - "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "node_modules/@material-table/core/node_modules/@mui/x-date-pickers": { + "version": "5.0.20", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz", + "integrity": "sha512-ERukSeHIoNLbI1C2XRhF9wRhqfsr+Q4B1SAw2ZlU7CWgcG8UBOxgqRKDEOVAIoSWL+DWT6GRuQjOKvj6UXZceA==", + "license": "MIT", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.18.9", + "@date-io/core": "^2.15.0", + "@date-io/date-fns": "^2.15.0", + "@date-io/dayjs": "^2.15.0", + "@date-io/luxon": "^2.15.0", + "@date-io/moment": "^2.15.0", + "@mui/utils": "^5.10.3", + "@types/react-transition-group": "^4.4.5", + "clsx": "^1.2.1", + "prop-types": "^15.7.2", + "react-transition-group": "^4.4.5", + "rifm": "^0.12.1" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui" }, "peerDependencies": { - "postcss": "^8.2" + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "date-fns": "^2.25.0", + "dayjs": "^1.10.7", + "luxon": "^1.28.0 || ^2.0.0 || ^3.0.0", + "moment": "^2.29.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } } }, - "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", - "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "node_modules/@material-table/core/node_modules/@mui/x-date-pickers/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@material-table/core/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", "dependencies": { - "@csstools/selector-specificity": "^2.0.0", - "postcss-selector-parser": "^6.0.10" + "@babel/runtime": "^7.21.0" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=0.11" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://opencollective.com/date-fns" } }, - "node_modules/@csstools/postcss-nested-calc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", - "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, + "node_modules/@material-table/core/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", + "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", + "license": "MIT", "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://opencollective.com/mui-org" } }, - "node_modules/@csstools/postcss-normalize-display-values": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", - "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "node_modules/@mui/icons-material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz", + "integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.26.0" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@mui/material": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-oklab-function": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", - "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "node_modules/@mui/material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", + "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", + "license": "MIT", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.5.0", + "@mui/system": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", - "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.3" + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } } }, - "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", - "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "node_modules/@mui/material/node_modules/@mui/system": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.9", + "@mui/styled-engine": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", - "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", - "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" }, "engines": { - "node": "^14 || >=16" + "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } } }, - "node_modules/@csstools/postcss-unset-value": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", - "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "node_modules/@mui/styles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styles/-/styles-6.5.0.tgz", + "integrity": "sha512-DeE/S/l6adnMpKfgx6l7UaQwYuf+gD4FCp6En3Vdg2Er+CTArj4DcHNFVzb8HZ2nNqACwmSm16/P08m1vAxv2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/hash": "^0.9.2", + "@mui/private-theming": "^6.4.9", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "hoist-non-react-statics": "^3.3.2", + "jss": "^10.10.0", + "jss-plugin-camel-case": "^10.10.0", + "jss-plugin-default-unit": "^10.10.0", + "jss-plugin-global": "^10.10.0", + "jss-plugin-nested": "^10.10.0", + "jss-plugin-props-sort": "^10.10.0", + "jss-plugin-rule-value-function": "^10.10.0", + "jss-plugin-vendor-prefixer": "^10.10.0", + "prop-types": "^15.8.1" + }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss": "^8.2" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@csstools/selector-specificity": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", - "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "postcss-selector-parser": "^6.0.10" - } - }, - "node_modules/@date-io/core": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", - "integrity": "sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } }, - "node_modules/@date-io/dayjs": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.17.0.tgz", - "integrity": "sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==", + "node_modules/@mui/system/node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", "license": "MIT", "dependencies": { - "@date-io/core": "^2.17.0" + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "dayjs": "^1.8.17" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "dayjs": { + "@types/react": { "optional": true } } }, - "node_modules/@date-io/luxon": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.17.0.tgz", - "integrity": "sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==", + "node_modules/@mui/system/node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", "license": "MIT", "dependencies": { - "@date-io/core": "^2.17.0" + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "luxon": "^1.21.3 || ^2.x || ^3.x" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "luxon": { + "@types/react": { "optional": true } } }, - "node_modules/@date-io/moment": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.17.0.tgz", - "integrity": "sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==", + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", "license": "MIT", - "dependencies": { - "@date-io/core": "^2.17.0" - }, "peerDependencies": { - "moment": "^2.24.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "moment": { + "@types/react": { "optional": true } } }, - "node_modules/@emotion/babel-plugin": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", - "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", + "node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.2.0", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, "engines": { - "node": ">=10" + "node": ">=14.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@emotion/cache": { - "version": "11.13.1", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", - "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.0", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/cache/node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@emotion/core": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@emotion/core/-/core-11.0.0.tgz", - "integrity": "sha512-w4sE3AmHmyG6RDKf6mIbtHpgJUSJ2uGvPQb8VXFL7hFjMPibE8IiehG8cMX3Ztm4svfCQV6KqusQbeIOkurBcA==", - "license": "MIT" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", - "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "hoist-non-react-statics": "^3.3.1" + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "react": ">=16.8.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -2483,12866 +2420,4504 @@ } } }, - "node_modules/@emotion/serialize": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", - "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.1", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/serialize/node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz", - "integrity": "sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==", + "node_modules/@mui/x-date-pickers": { + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", + "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.12.0", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.0", - "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", - "@emotion/utils": "^1.4.0" + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@types/react": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { "optional": true } } }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", - "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", - "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=14.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=14" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/popperjs" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "license": "MIT", + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", "dependencies": { - "type-fest": "^0.20.2" + "@swc/helpers": "^0.5.0" }, "engines": { - "node": ">=8" + "node": ">= 12" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/@react-keycloak/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@react-keycloak/core/-/core-3.2.0.tgz", + "integrity": "sha512-1yzU7gQzs+6E1v6hGqxy0Q+kpMHg9sEcke2yxZR29WoU8KNE8E50xS6UbI8N7rWsgyYw8r9W1cUPCOF48MYjzw==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "react-fast-compare": "^3.2.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "patreon", + "url": "https://www.patreon.com/reactkeycloak" + }, + "peerDependencies": { + "react": ">=16" } }, - "node_modules/@eslint/js": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", - "integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", - "dev": true, + "node_modules/@react-keycloak/web": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@react-keycloak/web/-/web-3.4.0.tgz", + "integrity": "sha512-yKKSCyqBtn7dt+VckYOW1IM5NW999pPkxDZOXqJ6dfXPXstYhOQCkTZqh8l7UL14PkpsoaHDh7hSJH8whah01g==", "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@babel/runtime": "^7.9.0", + "@react-keycloak/core": "^3.2.0", + "hoist-non-react-statics": "^3.3.2" }, "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@hello-pangea/dnd": { - "version": "16.6.0", - "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-16.6.0.tgz", - "integrity": "sha512-vfZ4GydqbtUPXSLfAvKvXQ6xwRzIjUSjVU0Sx+70VOhc2xx6CdmJXJ8YhH70RpbTUGjxctslQTHul9sIOxCfFQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.24.1", - "css-box-model": "^1.2.1", - "memoize-one": "^6.0.0", - "raf-schd": "^4.0.3", - "react-redux": "^8.1.3", - "redux": "^4.2.1", - "use-memo-one": "^1.1.3" + "type": "patreon", + "url": "https://www.patreon.com/reactkeycloak" }, "peerDependencies": { - "react": "^16.8.5 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + "keycloak-js": ">=9.0.2", + "react": ">=16.8", + "react-dom": ">=16.8", + "typescript": ">=3.8" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@hello-pangea/dnd/node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/@hello-pangea/dnd/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@hello-pangea/dnd/node_modules/react-redux": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", - "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", + "node_modules/@reduxjs/toolkit": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", + "integrity": "sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/use-sync-external-store": "^0.0.3", - "hoist-non-react-statics": "^3.3.2", - "react-is": "^18.0.0", - "use-sync-external-store": "^1.0.0" + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" }, "peerDependencies": { - "@types/react": "^16.8 || ^17.0 || ^18.0", - "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0", - "react-native": ">=0.59", - "redux": "^4 || ^5.0.0-beta.0" + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" }, "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { + "react": { "optional": true }, - "redux": { + "react-redux": { "optional": true } } }, - "node_modules/@hookform/error-message": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hookform/error-message/-/error-message-2.0.1.tgz", - "integrity": "sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg==", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0", - "react-hook-form": "^7.0.0" + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "license": "Apache-2.0", + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "dequal": "^2.0.3" }, - "engines": { - "node": ">=10.10.0" + "peerDependencies": { + "react": ">=16.8.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "engines": { - "node": ">=12.22" + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "dequal": "^2.0.3" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "react": ">=16.8.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", - "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^16.0.0", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/types/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/@jest/types/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", - "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", - "dependencies": { - "@jridgewell/set-array": "^1.0.0", - "@jridgewell/sourcemap-codec": "^1.4.10" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", - "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", - "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" - }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", - "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, - "node_modules/@mapbox/jsonlint-lines-primitives": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", - "integrity": "sha1-zlblOfg1UrWNENZy6k1vya3HsjQ=", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@mapbox/mapbox-gl-supported": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz", - "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==" - }, - "node_modules/@mapbox/point-geometry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", - "integrity": "sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI=" - }, - "node_modules/@mapbox/tiny-sdf": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", - "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==" - }, - "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" - }, - "node_modules/@mapbox/vector-tile": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", - "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", - "dependencies": { - "@mapbox/point-geometry": "~0.1.0" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@material-table/core": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@material-table/core/-/core-6.1.4.tgz", - "integrity": "sha512-PCbVyf1ULWBDbZx1tukJg8rPjlRdORo/mjtLHO4+ZgtXvFyVZ3qtd+5E7ouWuga0JQka0v3Gv4c+oaI8RmsW7w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.19.0", - "@date-io/core": "^2.16.0", - "@date-io/date-fns": "^2.16.0", - "@emotion/core": "^11.0.0", - "@emotion/react": "^11.10.4", - "@emotion/styled": "^11.10.4", - "@hello-pangea/dnd": "^16.0.0", - "@mui/icons-material": ">=5.10.6", - "@mui/material": ">=5.10.7", - "@mui/x-date-pickers": "^5.0.3", - "classnames": "^2.3.2", - "date-fns": "^2.29.3", - "debounce": "^1.2.1", - "deep-eql": "^4.1.1", - "deepmerge": "^4.2.2", - "prop-types": "^15.8.1", - "react-double-scrollbar": "0.0.15", - "uuid": "^9.0.0", - "zustand": "^4.1.1" - }, - "peerDependencies": { - "@mui/system": ">=5.10.7", - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@material-table/core/node_modules/@date-io/date-fns": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.17.0.tgz", - "integrity": "sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==", - "license": "MIT", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "date-fns": "^2.0.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - } - } - }, - "node_modules/@material-table/core/node_modules/@mui/core-downloads-tracker": { - "version": "5.16.11", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.11.tgz", - "integrity": "sha512-2eVDGg9OvIXNRmfDUQyKYH+jNcjdv1JkCH5F2YDgUye5fMX5nxGiYHAUe1BXaXyDMaLSwXC7LRksEKMiIQsFdw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - } - }, - "node_modules/@material-table/core/node_modules/@mui/material": { - "version": "5.16.11", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.11.tgz", - "integrity": "sha512-uoc67oecKdnVKaMHBVE433YrMuxQs22xY5nIjRb5sAPB+GaeZQWp8brQ3/adeH6k2IDa8+9i2IVd4fNLuvHSvA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.16.11", - "@mui/system": "^5.16.8", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.8", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^18.3.1", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@material-table/core/node_modules/@mui/material/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@material-table/core/node_modules/@mui/x-date-pickers": { - "version": "5.0.20", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz", - "integrity": "sha512-ERukSeHIoNLbI1C2XRhF9wRhqfsr+Q4B1SAw2ZlU7CWgcG8UBOxgqRKDEOVAIoSWL+DWT6GRuQjOKvj6UXZceA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.9", - "@date-io/core": "^2.15.0", - "@date-io/date-fns": "^2.15.0", - "@date-io/dayjs": "^2.15.0", - "@date-io/luxon": "^2.15.0", - "@date-io/moment": "^2.15.0", - "@mui/utils": "^5.10.3", - "@types/react-transition-group": "^4.4.5", - "clsx": "^1.2.1", - "prop-types": "^15.7.2", - "react-transition-group": "^4.4.5", - "rifm": "^0.12.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui" - }, - "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "date-fns": "^2.25.0", - "dayjs": "^1.10.7", - "luxon": "^1.28.0 || ^2.0.0 || ^3.0.0", - "moment": "^2.29.1", - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } - } - }, - "node_modules/@material-table/core/node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, - "node_modules/@material-table/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@material-table/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@mui/core-downloads-tracker": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.5.tgz", - "integrity": "sha512-3J96098GrC95XsLw/TpGNMxhUOnoG9NZ/17Pfk1CrJj+4rcuolsF2RdF3XAFTu/3a/A+5ouxlSIykzYz6Ee87g==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - } - }, - "node_modules/@mui/icons-material": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.5.tgz", - "integrity": "sha512-SbxFtO5I4cXfvhjAMgGib/t2lQUzcEzcDFYiRHRufZUeMMeXuoKaGsptfwAHTepYkv0VqcCwvxtvtWbpZLAbjQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@mui/material": "^6.1.5", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.5.tgz", - "integrity": "sha512-rhaxC7LnlOG8zIVYv7BycNbWkC5dlm9A/tcDUp0CuwA7Zf9B9JP6M3rr50cNKxI7Z0GIUesAT86ceVm44quwnQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/core-downloads-tracker": "^6.1.5", - "@mui/system": "^6.1.5", - "@mui/types": "^7.2.18", - "@mui/utils": "^6.1.5", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.11", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^18.3.1", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.1.5", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.5.tgz", - "integrity": "sha512-FJqweqEXk0KdtTho9C2h6JEKXsOT7MAVH2Uj3N5oIqs6YKxnwBn2/zL2QuYYEtj5OJ87rEUnCfFic6ldClvzJw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^6.1.5", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.5.tgz", - "integrity": "sha512-tiyWzMkHeWlOoE6AqomWvYvdml8Nv5k5T+LDwOiwHEawx8P9Lyja6ZwWPU6xljwPXYYPT2KBp1XvMly7dsK46A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@emotion/cache": "^11.13.1", - "@emotion/serialize": "^1.3.2", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.5.tgz", - "integrity": "sha512-vPM9ocQ8qquRDByTG3XF/wfYTL7IWL/20EiiKqByLDps8wOmbrDG9rVznSE3ZbcjFCFfMRMhtxvN92bwe/63SA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/private-theming": "^6.1.5", - "@mui/styled-engine": "^6.1.5", - "@mui/types": "^7.2.18", - "@mui/utils": "^6.1.5", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", - "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/types": "^7.2.18", - "@types/prop-types": "^15.7.13", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/material/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@mui/private-theming": { - "version": "5.16.8", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.8.tgz", - "integrity": "sha512-3Vl9yFVLU6T3CFtxRMQTcJ60Ijv7wxQi4yjH92+9YXcsqvVspeIYoocqNoIV/1bXGYfyWu5zrCmwQVHaGY7bug==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.8", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "5.16.8", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.8.tgz", - "integrity": "sha512-OFdgFf8JczSRs0kvWGdSn0ZeXxWrY0LITDPJ/nAtLEvUUTyrlFaO4il3SECX8ruzvf1VnAxHx4M/4mX9oOn9yA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/styles": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/styles/-/styles-6.1.5.tgz", - "integrity": "sha512-AhA/zgaXVg32GsNAUrt0Ojqjet7cVzQ51x4DVMaAA/ohtTh3EEyOEQZCMxhGpxJRRM/ya6fk6xiKd9xSNjr9mA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@emotion/hash": "^0.9.2", - "@mui/private-theming": "^6.1.5", - "@mui/types": "^7.2.18", - "@mui/utils": "^6.1.5", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "hoist-non-react-statics": "^3.3.2", - "jss": "^10.10.0", - "jss-plugin-camel-case": "^10.10.0", - "jss-plugin-default-unit": "^10.10.0", - "jss-plugin-global": "^10.10.0", - "jss-plugin-nested": "^10.10.0", - "jss-plugin-props-sort": "^10.10.0", - "jss-plugin-rule-value-function": "^10.10.0", - "jss-plugin-vendor-prefixer": "^10.10.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styles/node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@mui/styles/node_modules/@mui/private-theming": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.5.tgz", - "integrity": "sha512-FJqweqEXk0KdtTho9C2h6JEKXsOT7MAVH2Uj3N5oIqs6YKxnwBn2/zL2QuYYEtj5OJ87rEUnCfFic6ldClvzJw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^6.1.5", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styles/node_modules/@mui/utils": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", - "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/types": "^7.2.18", - "@types/prop-types": "^15.7.13", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styles/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/styles/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@mui/system": { - "version": "5.16.8", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.8.tgz", - "integrity": "sha512-L32TaFDFpGIi1g6ysRtmhc9zDgrlxDXu3NlrGE8gAsQw/ziHrPdr0PNr20O0POUshA1q14W4dNZ/z0Nx2F9lhA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.8", - "@mui/styled-engine": "^5.16.8", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.8", - "clsx": "^2.1.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/system/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/types": { - "version": "7.2.18", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz", - "integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "5.16.8", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.8.tgz", - "integrity": "sha512-P/yb7BSWallQUeiNGxb+TM8epHteIUC8gzNTdPV2VfKhVY/EnGliHgt5np0GPkjQ7EzwDi/+gBevrAJtf+K94A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/types": "^7.2.15", - "@types/prop-types": "^15.7.12", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@mui/x-date-pickers": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.21.0.tgz", - "integrity": "sha512-WLpuTu3PvhYwd7IAJSuDWr1Zd8c5C8Cc7rpAYCaV5+tGBoEP0C2UKqClMR4F1wTiU2a7x3dzgQzkcgK72yyqDw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.21.0", - "@types/react-transition-group": "^4.4.11", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0", - "@mui/system": "^5.15.14 || ^6.0.0", - "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", - "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", - "dayjs": "^1.10.7", - "luxon": "^3.0.2", - "moment": "^2.29.4", - "moment-hijri": "^2.1.2", - "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "date-fns": { - "optional": true - }, - "date-fns-jalali": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - }, - "moment-hijri": { - "optional": true - }, - "moment-jalaali": { - "optional": true - } - } - }, - "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", - "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/types": "^7.2.18", - "@types/prop-types": "^15.7.13", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-date-pickers/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/x-date-pickers/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@mui/x-internals": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz", - "integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0" - } - }, - "node_modules/@mui/x-internals/node_modules/@mui/utils": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", - "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/types": "^7.2.18", - "@types/prop-types": "^15.7.13", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-internals/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@mui/x-internals/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { - "version": "5.1.1-v1", - "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", - "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", - "dependencies": { - "eslint-scope": "5.1.1" - } - }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", - "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", - "dependencies": { - "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^3.0.0", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "@types/webpack": "4.x || 5.x", - "react-refresh": ">=0.10.0 <1.0.0", - "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <5.0.0", - "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x", - "webpack-hot-middleware": "2.x", - "webpack-plugin-serve": "0.x || 1.x" - }, - "peerDependenciesMeta": { - "@types/webpack": { - "optional": true - }, - "sockjs-client": { - "optional": true - }, - "type-fest": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - }, - "webpack-hot-middleware": { - "optional": true - }, - "webpack-plugin-serve": { - "optional": true - } - } - }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.4.1.tgz", - "integrity": "sha512-NmhoilMDyIfQiOSdQgxpVH2tC2u85Y0mVijtBNbI9kcDYLEiW/r6vKYVKtkyU+C4qobXhGMPfZ70PTc0lysSVA==", - "dependencies": { - "@swc/helpers": "^0.4.14" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" - } - }, - "node_modules/@react-keycloak/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@react-keycloak/core/-/core-3.2.0.tgz", - "integrity": "sha512-1yzU7gQzs+6E1v6hGqxy0Q+kpMHg9sEcke2yxZR29WoU8KNE8E50xS6UbI8N7rWsgyYw8r9W1cUPCOF48MYjzw==", - "dependencies": { - "react-fast-compare": "^3.2.0" - }, - "funding": { - "type": "patreon", - "url": "https://www.patreon.com/reactkeycloak" - }, - "peerDependencies": { - "react": ">=16" - } - }, - "node_modules/@react-keycloak/web": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@react-keycloak/web/-/web-3.4.0.tgz", - "integrity": "sha512-yKKSCyqBtn7dt+VckYOW1IM5NW999pPkxDZOXqJ6dfXPXstYhOQCkTZqh8l7UL14PkpsoaHDh7hSJH8whah01g==", - "dependencies": { - "@babel/runtime": "^7.9.0", - "@react-keycloak/core": "^3.2.0", - "hoist-non-react-statics": "^3.3.2" - }, - "funding": { - "type": "patreon", - "url": "https://www.patreon.com/reactkeycloak" - }, - "peerDependencies": { - "keycloak-js": ">=9.0.2", - "react": ">=16.8", - "react-dom": ">=16.8", - "typescript": ">=3.8" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz", - "integrity": "sha512-GU2TNBQVofL09VGmuSioNPQIu6Ml0YLf4EJhgj0AvBadRlCGzUWet8372LjvO4fqKZF2vH1xU0htAa7BrK9pZg==", - "dependencies": { - "immer": "^9.0.16", - "redux": "^4.2.0", - "redux-thunk": "^2.4.2", - "reselect": "^4.1.7" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18", - "react-redux": "^7.2.1 || ^8.0.2" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@restart/hooks": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.7.tgz", - "integrity": "sha512-ZbjlEHcG+FQtpDPHd7i4FzNNvJf2enAwZfJbpM8CW7BhmOAbsHpZe3tsHwfQUrBuyrxWqPYp2x5UMnilWcY22A==", - "dependencies": { - "dequal": "^2.0.2" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.4.1.tgz", - "integrity": "sha512-J7wFOx2DcmkBqCqiZgDsggLO7faiNh4Nv1/v80FmbRgP+MYpwaVDKKXLC69DA4+ejgNIsBP5ORtC74EZqO1j8A==", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@popperjs/core": "^2.11.5", - "@react-aria/ssr": "^3.2.0", - "@restart/hooks": "^0.4.7", - "@types/warning": "^3.0.0", - "dequal": "^2.0.2", - "dom-helpers": "^5.2.0", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, - "node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", - "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/pluginutils/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.7.0.tgz", - "integrity": "sha512-Jh4t/593gxs0lJZ/z3NnasKlplXT2f+4y/LZYuaKZW5KAaiVFL/fThhs+17EbUd53jUVJ0QudYCBGbN/psvaqg==" - }, - "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@stomp/stompjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", - "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==" - }, - "node_modules/@surma/rollup-plugin-off-main-thread": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", - "dependencies": { - "ejs": "^3.1.6", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" - } - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", - "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", - "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", - "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", - "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", - "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", - "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", - "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", - "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", - "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", - "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", - "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", - "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", - "@svgr/babel-plugin-transform-svg-component": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", - "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", - "dependencies": { - "@svgr/plugin-jsx": "^5.5.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^7.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", - "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", - "dependencies": { - "@babel/types": "^7.12.6" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", - "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", - "dependencies": { - "@babel/core": "^7.12.3", - "@svgr/babel-preset": "^5.5.0", - "@svgr/hast-util-to-babel-ast": "^5.5.0", - "svg-parser": "^2.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-svgo": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", - "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", - "dependencies": { - "cosmiconfig": "^7.0.0", - "deepmerge": "^4.2.2", - "svgo": "^1.2.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/webpack": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", - "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/plugin-transform-react-constant-elements": "^7.12.1", - "@babel/preset-env": "^7.12.1", - "@babel/preset-react": "^7.12.5", - "@svgr/core": "^5.5.0", - "@svgr/plugin-jsx": "^5.5.0", - "@svgr/plugin-svgo": "^5.5.0", - "loader-utils": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@swc/helpers": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz", - "integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@testing-library/dom": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", - "integrity": "sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "^5.0.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.4.4", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react": { - "version": "12.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", - "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0", - "@types/react-dom": "<18.0.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": "<18.0.0", - "react-dom": "<18.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/@types/react-dom": { - "version": "17.0.20", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", - "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", - "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@turf/along": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", - "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/angle": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-6.5.0.tgz", - "integrity": "sha512-4pXMbWhFofJJAOvTMCns6N4C8CMd5Ih4O2jSAG9b3dDHakj3O4yN1+Zbm+NUei+eVEZ9gFeVp9svE3aMDenIkw==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/area": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", - "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bbox": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", - "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bbox-clip": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-6.5.0.tgz", - "integrity": "sha512-F6PaIRF8WMp8EmgU/Ke5B1Y6/pia14UAYB5TiBC668w5rVVjy5L8rTm/m2lEkkDMHlzoP9vNY4pxpNthE7rLcQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bbox-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-6.5.0.tgz", - "integrity": "sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bearing": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz", - "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bezier-spline": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-6.5.0.tgz", - "integrity": "sha512-vokPaurTd4PF96rRgGVm6zYYC5r1u98ZsG+wZEv9y3kJTuJRX/O3xIY2QnTGTdbVmAJN1ouOsD0RoZYaVoXORQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-clockwise": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-6.5.0.tgz", - "integrity": "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-contains": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz", - "integrity": "sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/boolean-point-on-line": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-crosses": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-6.5.0.tgz", - "integrity": "sha512-gvshbTPhAHporTlQwBJqyfW+2yV8q/mOTxG6PzRVl6ARsqNoqYQWkd4MLug7OmAqVyBzLK3201uAeBjxbGw0Ng==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/polygon-to-line": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-disjoint": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-6.5.0.tgz", - "integrity": "sha512-rZ2ozlrRLIAGo2bjQ/ZUu4oZ/+ZjGvLkN5CKXSKBcu6xFO6k2bgqeM8a1836tAW+Pqp/ZFsTA5fZHsJZvP2D5g==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/polygon-to-line": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-equal": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz", - "integrity": "sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q==", - "dependencies": { - "@turf/clean-coords": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "geojson-equality": "0.1.6" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-intersects": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-6.5.0.tgz", - "integrity": "sha512-nIxkizjRdjKCYFQMnml6cjPsDOBCThrt+nkqtSEcxkKMhAQj5OO7o2CecioNTaX8EayqwMGVKcsz27oP4mKPTw==", - "dependencies": { - "@turf/boolean-disjoint": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-overlap": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-6.5.0.tgz", - "integrity": "sha512-8btMIdnbXVWUa1M7D4shyaSGxLRw6NjMcqKBcsTXcZdnaixl22k7ar7BvIzkaRYN3SFECk9VGXfLncNS3ckQUw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/line-overlap": "^6.5.0", - "@turf/meta": "^6.5.0", - "geojson-equality": "0.1.6" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-parallel": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-6.5.0.tgz", - "integrity": "sha512-aSHJsr1nq9e5TthZGZ9CZYeXklJyRgR5kCLm5X4urz7+MotMOp/LsGOsvKvK9NeUl9+8OUmfMn8EFTT8LkcvIQ==", - "dependencies": { - "@turf/clean-coords": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/line-segment": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-point-in-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", - "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-point-on-line": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz", - "integrity": "sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-within": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-6.5.0.tgz", - "integrity": "sha512-YQB3oU18Inx35C/LU930D36RAVe7LDXk1kWsQ8mLmuqYn9YdPsDQTMTkLJMhoQ8EbN7QTdy333xRQ4MYgToteQ==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/boolean-point-on-line": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/buffer": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-6.5.0.tgz", - "integrity": "sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/center": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/projection": "^6.5.0", - "d3-geo": "1.7.1", - "turf-jsts": "*" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/center": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/center/-/center-6.5.0.tgz", - "integrity": "sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/center-mean": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-6.5.0.tgz", - "integrity": "sha512-AAX6f4bVn12pTVrMUiB9KrnV94BgeBKpyg3YpfnEbBpkN/znfVhL8dG8IxMAxAoSZ61Zt9WLY34HfENveuOZ7Q==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/center-median": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-6.5.0.tgz", - "integrity": "sha512-dT8Ndu5CiZkPrj15PBvslpuf01ky41DEYEPxS01LOxp5HOUHXp1oJxsPxvc+i/wK4BwccPNzU1vzJ0S4emd1KQ==", - "dependencies": { - "@turf/center-mean": "^6.5.0", - "@turf/centroid": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/center-of-mass": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz", - "integrity": "sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ==", - "dependencies": { - "@turf/centroid": "^6.5.0", - "@turf/convex": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/centroid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", - "integrity": "sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/circle": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-6.5.0.tgz", - "integrity": "sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A==", - "dependencies": { - "@turf/destination": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clean-coords": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", - "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clone": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-6.5.0.tgz", - "integrity": "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clusters": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-6.5.0.tgz", - "integrity": "sha512-Y6gfnTJzQ1hdLfCsyd5zApNbfLIxYEpmDibHUqR5z03Lpe02pa78JtgrgUNt1seeO/aJ4TG1NLN8V5gOrHk04g==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clusters-dbscan": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-6.5.0.tgz", - "integrity": "sha512-SxZEE4kADU9DqLRiT53QZBBhu8EP9skviSyl+FGj08Y01xfICM/RR9ACUdM0aEQimhpu+ZpRVcUK+2jtiCGrYQ==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0", - "density-clustering": "1.3.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clusters-kmeans": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-6.5.0.tgz", - "integrity": "sha512-DwacD5+YO8kwDPKaXwT9DV46tMBVNsbi1IzdajZu1JDSWoN7yc7N9Qt88oi+p30583O0UPVkAK+A10WAQv4mUw==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "skmeans": "0.9.7" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/collect": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-6.5.0.tgz", - "integrity": "sha512-4dN/T6LNnRg099m97BJeOcTA5fSI8cu87Ydgfibewd2KQwBexO69AnjEFqfPX3Wj+Zvisj1uAVIZbPmSSrZkjg==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", - "rbush": "2.x" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/combine": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-6.5.0.tgz", - "integrity": "sha512-Q8EIC4OtAcHiJB3C4R+FpB4LANiT90t17uOd851qkM2/o6m39bfN5Mv0PWqMZIHWrrosZqRqoY9dJnzz/rJxYQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/concave": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-6.5.0.tgz", - "integrity": "sha512-I/sUmUC8TC5h/E2vPwxVht+nRt+TnXIPRoztDFvS8/Y0+cBDple9inLSo9nnPXMXidrBlGXZ9vQx/BjZUJgsRQ==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/tin": "^6.5.0", - "topojson-client": "3.x", - "topojson-server": "3.x" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/convex": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-6.5.0.tgz", - "integrity": "sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0", - "concaveman": "*" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/destination": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz", - "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/difference": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-6.5.0.tgz", - "integrity": "sha512-l8iR5uJqvI+5Fs6leNbhPY5t/a3vipUF/3AeVLpwPQcgmedNXyheYuy07PcMGH5Jdpi5gItOiTqwiU/bUH4b3A==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "polygon-clipping": "^0.15.3" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/dissolve": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-6.5.0.tgz", - "integrity": "sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "polygon-clipping": "^0.15.3" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/distance": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", - "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/distance-weight": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-6.5.0.tgz", - "integrity": "sha512-a8qBKkgVNvPKBfZfEJZnC3DV7dfIsC3UIdpRci/iap/wZLH41EmS90nM+BokAJflUHYy8PqE44wySGWHN1FXrQ==", - "dependencies": { - "@turf/centroid": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/ellipse": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-6.5.0.tgz", - "integrity": "sha512-kuXtwFviw/JqnyJXF1mrR/cb496zDTSbGKtSiolWMNImYzGGkbsAsFTjwJYgD7+4FixHjp0uQPzo70KDf3AIBw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/rhumb-destination": "^6.5.0", - "@turf/transform-rotate": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/envelope": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-6.5.0.tgz", - "integrity": "sha512-9Z+FnBWvOGOU4X+fMZxYFs1HjFlkKqsddLuMknRaqcJd6t+NIv5DWvPtDL8ATD2GEExYDiFLwMdckfr1yqJgHA==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/bbox-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/explode": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-6.5.0.tgz", - "integrity": "sha512-6cSvMrnHm2qAsace6pw9cDmK2buAlw8+tjeJVXMfMyY+w7ZUi1rprWMsY92J7s2Dar63Bv09n56/1V7+tcj52Q==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/flatten": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-6.5.0.tgz", - "integrity": "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/flip": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-6.5.0.tgz", - "integrity": "sha512-oyikJFNjt2LmIXQqgOGLvt70RgE2lyzPMloYWM7OR5oIFGRiBvqVD2hA6MNw6JewIm30fWZ8DQJw1NHXJTJPbg==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/great-circle": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-6.5.0.tgz", - "integrity": "sha512-7ovyi3HaKOXdFyN7yy1yOMa8IyOvV46RC1QOQTT+RYUN8ke10eyqExwBpL9RFUPvlpoTzoYbM/+lWPogQlFncg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/hex-grid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-6.5.0.tgz", - "integrity": "sha512-Ln3tc2tgZT8etDOldgc6e741Smg1CsMKAz1/Mlel+MEL5Ynv2mhx3m0q4J9IB1F3a4MNjDeVvm8drAaf9SF33g==", - "dependencies": { - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/intersect": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/interpolate": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-6.5.0.tgz", - "integrity": "sha512-LSH5fMeiGyuDZ4WrDJNgh81d2DnNDUVJtuFryJFup8PV8jbs46lQGfI3r1DJ2p1IlEJIz3pmAZYeTfMMoeeohw==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/centroid": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/hex-grid": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/point-grid": "^6.5.0", - "@turf/square-grid": "^6.5.0", - "@turf/triangle-grid": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/intersect": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-6.5.0.tgz", - "integrity": "sha512-2legGJeKrfFkzntcd4GouPugoqPUjexPZnOvfez+3SfIMrHvulw8qV8u7pfVyn2Yqs53yoVCEjS5sEpvQ5YRQg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "polygon-clipping": "^0.15.3" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/isobands": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-6.5.0.tgz", - "integrity": "sha512-4h6sjBPhRwMVuFaVBv70YB7eGz+iw0bhPRnp+8JBdX1UPJSXhoi/ZF2rACemRUr0HkdVB/a1r9gC32vn5IAEkw==", - "dependencies": { - "@turf/area": "^6.5.0", - "@turf/bbox": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/explode": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "object-assign": "*" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/isolines": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-6.5.0.tgz", - "integrity": "sha512-6ElhiLCopxWlv4tPoxiCzASWt/jMRvmp6mRYrpzOm3EUl75OhHKa/Pu6Y9nWtCMmVC/RcWtiiweUocbPLZLm0A==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "object-assign": "*" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/kinks": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-6.5.0.tgz", - "integrity": "sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/length": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz", - "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==", - "dependencies": { - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-arc": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-6.5.0.tgz", - "integrity": "sha512-I6c+V6mIyEwbtg9P9zSFF89T7QPe1DPTG3MJJ6Cm1MrAY0MdejwQKOpsvNl8LDU2ekHOlz2kHpPVR7VJsoMllA==", - "dependencies": { - "@turf/circle": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-chunk": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-6.5.0.tgz", - "integrity": "sha512-i1FGE6YJaaYa+IJesTfyRRQZP31QouS+wh/pa6O3CC0q4T7LtHigyBSYjrbjSLfn2EVPYGlPCMFEqNWCOkC6zg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/length": "^6.5.0", - "@turf/line-slice-along": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-intersect": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz", - "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-segment": "^6.5.0", - "@turf/meta": "^6.5.0", - "geojson-rbush": "3.x" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-offset": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-6.5.0.tgz", - "integrity": "sha512-CEXZbKgyz8r72qRvPchK0dxqsq8IQBdH275FE6o4MrBkzMcoZsfSjghtXzKaz9vvro+HfIXal0sTk2mqV1lQTw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-overlap": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-6.5.0.tgz", - "integrity": "sha512-xHOaWLd0hkaC/1OLcStCpfq55lPHpPNadZySDXYiYjEz5HXr1oKmtMYpn0wGizsLwrOixRdEp+j7bL8dPt4ojQ==", - "dependencies": { - "@turf/boolean-point-on-line": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-segment": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/nearest-point-on-line": "^6.5.0", - "deep-equal": "1.x", - "geojson-rbush": "3.x" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-segment": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz", - "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-slice": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-6.5.0.tgz", - "integrity": "sha512-vDqJxve9tBHhOaVVFXqVjF5qDzGtKWviyjbyi2QnSnxyFAmLlLnBfMX8TLQCAf2GxHibB95RO5FBE6I2KVPRuw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/nearest-point-on-line": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-slice-along": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-6.5.0.tgz", - "integrity": "sha512-KHJRU6KpHrAj+BTgTNqby6VCTnDzG6a1sJx/I3hNvqMBLvWVA2IrkR9L9DtsQsVY63IBwVdQDqiwCuZLDQh4Ng==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-split": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-6.5.0.tgz", - "integrity": "sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/line-segment": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/nearest-point-on-line": "^6.5.0", - "@turf/square": "^6.5.0", - "@turf/truncate": "^6.5.0", - "geojson-rbush": "3.x" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/line-to-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-6.5.0.tgz", - "integrity": "sha512-qYBuRCJJL8Gx27OwCD1TMijM/9XjRgXH/m/TyuND4OXedBpIWlK5VbTIO2gJ8OCfznBBddpjiObLBrkuxTpN4Q==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/mask": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-6.5.0.tgz", - "integrity": "sha512-RQha4aU8LpBrmrkH8CPaaoAfk0Egj5OuXtv6HuCQnHeGNOQt3TQVibTA3Sh4iduq4EPxnZfDjgsOeKtrCA19lg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "polygon-clipping": "^0.15.3" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/meta": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", - "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/midpoint": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-6.5.0.tgz", - "integrity": "sha512-MyTzV44IwmVI6ec9fB2OgZ53JGNlgOpaYl9ArKoF49rXpL84F9rNATndbe0+MQIhdkw8IlzA6xVP4lZzfMNVCw==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/moran-index": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-6.5.0.tgz", - "integrity": "sha512-ItsnhrU2XYtTtTudrM8so4afBCYWNaB0Mfy28NZwLjB5jWuAsvyV+YW+J88+neK/ougKMTawkmjQqodNJaBeLQ==", - "dependencies": { - "@turf/distance-weight": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/nearest-point": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-6.5.0.tgz", - "integrity": "sha512-fguV09QxilZv/p94s8SMsXILIAMiaXI5PATq9d7YWijLxWUj6Q/r43kxyoi78Zmwwh1Zfqz9w+bCYUAxZ5+euA==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/nearest-point-on-line": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz", - "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/nearest-point-to-line": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-6.5.0.tgz", - "integrity": "sha512-PXV7cN0BVzUZdjj6oeb/ESnzXSfWmEMrsfZSDRgqyZ9ytdiIj/eRsnOXLR13LkTdXVOJYDBuf7xt1mLhM4p6+Q==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/point-to-line-distance": "^6.5.0", - "object-assign": "*" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/planepoint": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-6.5.0.tgz", - "integrity": "sha512-R3AahA6DUvtFbka1kcJHqZ7DMHmPXDEQpbU5WaglNn7NaCQg9HB0XM0ZfqWcd5u92YXV+Gg8QhC8x5XojfcM4Q==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/point-grid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-6.5.0.tgz", - "integrity": "sha512-Iq38lFokNNtQJnOj/RBKmyt6dlof0yhaHEDELaWHuECm1lIZLY3ZbVMwbs+nXkwTAHjKfS/OtMheUBkw+ee49w==", - "dependencies": { - "@turf/boolean-within": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/point-on-feature": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-6.5.0.tgz", - "integrity": "sha512-bDpuIlvugJhfcF/0awAQ+QI6Om1Y1FFYE8Y/YdxGRongivix850dTeXCo0mDylFdWFPGDo7Mmh9Vo4VxNwW/TA==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/center": "^6.5.0", - "@turf/explode": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/nearest-point": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/point-to-line-distance": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-6.5.0.tgz", - "integrity": "sha512-opHVQ4vjUhNBly1bob6RWy+F+hsZDH9SA0UW36pIRzfpu27qipU18xup0XXEePfY6+wvhF6yL/WgCO2IbrLqEA==", - "dependencies": { - "@turf/bearing": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/projection": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0", - "@turf/rhumb-distance": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/points-within-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-6.5.0.tgz", - "integrity": "sha512-YyuheKqjliDsBDt3Ho73QVZk1VXX1+zIA2gwWvuz8bR1HXOkcuwk/1J76HuFMOQI3WK78wyAi+xbkx268PkQzQ==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/polygon-smooth": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-6.5.0.tgz", - "integrity": "sha512-LO/X/5hfh/Rk4EfkDBpLlVwt3i6IXdtQccDT9rMjXEP32tRgy0VMFmdkNaXoGlSSKf/1mGqLl4y4wHd86DqKbg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/polygon-tangents": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-6.5.0.tgz", - "integrity": "sha512-sB4/IUqJMYRQH9jVBwqS/XDitkEfbyqRy+EH/cMRJURTg78eHunvJ708x5r6umXsbiUyQU4eqgPzEylWEQiunw==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/boolean-within": "^6.5.0", - "@turf/explode": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/nearest-point": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/polygon-to-line": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-6.5.0.tgz", - "integrity": "sha512-5p4n/ij97EIttAq+ewSnKt0ruvuM+LIDzuczSzuHTpq4oS7Oq8yqg5TQ4nzMVuK41r/tALCk7nAoBuw3Su4Gcw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/polygonize": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-6.5.0.tgz", - "integrity": "sha512-a/3GzHRaCyzg7tVYHo43QUChCspa99oK4yPqooVIwTC61npFzdrmnywMv0S+WZjHZwK37BrFJGFrZGf6ocmY5w==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/envelope": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/projection": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-6.5.0.tgz", - "integrity": "sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/random": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/random/-/random-6.5.0.tgz", - "integrity": "sha512-8Q25gQ/XbA7HJAe+eXp4UhcXM9aOOJFaxZ02+XSNwMvY8gtWSCBLVqRcW4OhqilgZ8PeuQDWgBxeo+BIqqFWFQ==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rectangle-grid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-6.5.0.tgz", - "integrity": "sha512-yQZ/1vbW68O2KsSB3OZYK+72aWz/Adnf7m2CMKcC+aq6TwjxZjAvlbCOsNUnMAuldRUVN1ph6RXMG4e9KEvKvg==", - "dependencies": { - "@turf/boolean-intersects": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rewind": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-6.5.0.tgz", - "integrity": "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==", - "dependencies": { - "@turf/boolean-clockwise": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rhumb-bearing": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-6.5.0.tgz", - "integrity": "sha512-jMyqiMRK4hzREjQmnLXmkJ+VTNTx1ii8vuqRwJPcTlKbNWfjDz/5JqJlb5NaFDcdMpftWovkW5GevfnuzHnOYA==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rhumb-destination": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-6.5.0.tgz", - "integrity": "sha512-RHNP1Oy+7xTTdRrTt375jOZeHceFbjwohPHlr9Hf68VdHHPMAWgAKqiX2YgSWDcvECVmiGaBKWus1Df+N7eE4Q==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rhumb-distance": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-6.5.0.tgz", - "integrity": "sha512-oKp8KFE8E4huC2Z1a1KNcFwjVOqa99isxNOwfo4g3SUABQ6NezjKDDrnvC4yI5YZ3/huDjULLBvhed45xdCrzg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/sample": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-6.5.0.tgz", - "integrity": "sha512-kSdCwY7el15xQjnXYW520heKUrHwRvnzx8ka4eYxX9NFeOxaFITLW2G7UtXb6LJK8mmPXI8Aexv23F2ERqzGFg==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/sector": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-6.5.0.tgz", - "integrity": "sha512-cYUOkgCTWqa23SOJBqxoFAc/yGCUsPRdn/ovbRTn1zNTm/Spmk6hVB84LCKOgHqvSF25i0d2kWqpZDzLDdAPbw==", - "dependencies": { - "@turf/circle": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/line-arc": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/shortest-path": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-6.5.0.tgz", - "integrity": "sha512-4de5+G7+P4hgSoPwn+SO9QSi9HY5NEV/xRJ+cmoFVRwv2CDsuOPDheHKeuIAhKyeKDvPvPt04XYWbac4insJMg==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/bbox-polygon": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/clean-coords": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/transform-scale": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/simplify": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-6.5.0.tgz", - "integrity": "sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg==", - "dependencies": { - "@turf/clean-coords": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/square": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz", - "integrity": "sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ==", - "dependencies": { - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/square-grid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-6.5.0.tgz", - "integrity": "sha512-mlR0ayUdA+L4c9h7p4k3pX6gPWHNGuZkt2c5II1TJRmhLkW2557d6b/Vjfd1z9OVaajb1HinIs1FMSAPXuuUrA==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/rectangle-grid": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/standard-deviational-ellipse": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-6.5.0.tgz", - "integrity": "sha512-02CAlz8POvGPFK2BKK8uHGUk/LXb0MK459JVjKxLC2yJYieOBTqEbjP0qaWhiBhGzIxSMaqe8WxZ0KvqdnstHA==", - "dependencies": { - "@turf/center-mean": "^6.5.0", - "@turf/ellipse": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/points-within-polygon": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/tag": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-6.5.0.tgz", - "integrity": "sha512-XwlBvrOV38CQsrNfrxvBaAPBQgXMljeU0DV8ExOyGM7/hvuGHJw3y8kKnQ4lmEQcmcrycjDQhP7JqoRv8vFssg==", - "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/tesselate": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-6.5.0.tgz", - "integrity": "sha512-M1HXuyZFCfEIIKkglh/r5L9H3c5QTEsnMBoZOFQiRnGPGmJWcaBissGb7mTFX2+DKE7FNWXh4TDnZlaLABB0dQ==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "earcut": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/tin": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-6.5.0.tgz", - "integrity": "sha512-YLYikRzKisfwj7+F+Tmyy/LE3d2H7D4kajajIfc9mlik2+esG7IolsX/+oUz1biguDYsG0DUA8kVYXDkobukfg==", - "dependencies": { - "@turf/helpers": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/transform-rotate": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-6.5.0.tgz", - "integrity": "sha512-A2Ip1v4246ZmpssxpcL0hhiVBEf4L8lGnSPWTgSv5bWBEoya2fa/0SnFX9xJgP40rMP+ZzRaCN37vLHbv1Guag==", - "dependencies": { - "@turf/centroid": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0", - "@turf/rhumb-destination": "^6.5.0", - "@turf/rhumb-distance": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/transform-scale": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-6.5.0.tgz", - "integrity": "sha512-VsATGXC9rYM8qTjbQJ/P7BswKWXHdnSJ35JlV4OsZyHBMxJQHftvmZJsFbOqVtQnIQIzf2OAly6rfzVV9QLr7g==", - "dependencies": { - "@turf/bbox": "^6.5.0", - "@turf/center": "^6.5.0", - "@turf/centroid": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0", - "@turf/rhumb-destination": "^6.5.0", - "@turf/rhumb-distance": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/transform-translate": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-6.5.0.tgz", - "integrity": "sha512-NABLw5VdtJt/9vSstChp93pc6oel4qXEos56RBMsPlYB8hzNTEKYtC146XJvyF4twJeeYS8RVe1u7KhoFwEM5w==", - "dependencies": { - "@turf/clone": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/rhumb-destination": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/triangle-grid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-6.5.0.tgz", - "integrity": "sha512-2jToUSAS1R1htq4TyLQYPTIsoy6wg3e3BQXjm2rANzw4wPQCXGOxrur1Fy9RtzwqwljlC7DF4tg0OnWr8RjmfA==", - "dependencies": { - "@turf/distance": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/intersect": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/truncate": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", - "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/turf": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-6.5.0.tgz", - "integrity": "sha512-ipMCPnhu59bh92MNt8+pr1VZQhHVuTMHklciQURo54heoxRzt1neNYZOBR6jdL+hNsbDGAECMuIpAutX+a3Y+w==", - "dependencies": { - "@turf/along": "^6.5.0", - "@turf/angle": "^6.5.0", - "@turf/area": "^6.5.0", - "@turf/bbox": "^6.5.0", - "@turf/bbox-clip": "^6.5.0", - "@turf/bbox-polygon": "^6.5.0", - "@turf/bearing": "^6.5.0", - "@turf/bezier-spline": "^6.5.0", - "@turf/boolean-clockwise": "^6.5.0", - "@turf/boolean-contains": "^6.5.0", - "@turf/boolean-crosses": "^6.5.0", - "@turf/boolean-disjoint": "^6.5.0", - "@turf/boolean-equal": "^6.5.0", - "@turf/boolean-intersects": "^6.5.0", - "@turf/boolean-overlap": "^6.5.0", - "@turf/boolean-parallel": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/boolean-point-on-line": "^6.5.0", - "@turf/boolean-within": "^6.5.0", - "@turf/buffer": "^6.5.0", - "@turf/center": "^6.5.0", - "@turf/center-mean": "^6.5.0", - "@turf/center-median": "^6.5.0", - "@turf/center-of-mass": "^6.5.0", - "@turf/centroid": "^6.5.0", - "@turf/circle": "^6.5.0", - "@turf/clean-coords": "^6.5.0", - "@turf/clone": "^6.5.0", - "@turf/clusters": "^6.5.0", - "@turf/clusters-dbscan": "^6.5.0", - "@turf/clusters-kmeans": "^6.5.0", - "@turf/collect": "^6.5.0", - "@turf/combine": "^6.5.0", - "@turf/concave": "^6.5.0", - "@turf/convex": "^6.5.0", - "@turf/destination": "^6.5.0", - "@turf/difference": "^6.5.0", - "@turf/dissolve": "^6.5.0", - "@turf/distance": "^6.5.0", - "@turf/distance-weight": "^6.5.0", - "@turf/ellipse": "^6.5.0", - "@turf/envelope": "^6.5.0", - "@turf/explode": "^6.5.0", - "@turf/flatten": "^6.5.0", - "@turf/flip": "^6.5.0", - "@turf/great-circle": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/hex-grid": "^6.5.0", - "@turf/interpolate": "^6.5.0", - "@turf/intersect": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/isobands": "^6.5.0", - "@turf/isolines": "^6.5.0", - "@turf/kinks": "^6.5.0", - "@turf/length": "^6.5.0", - "@turf/line-arc": "^6.5.0", - "@turf/line-chunk": "^6.5.0", - "@turf/line-intersect": "^6.5.0", - "@turf/line-offset": "^6.5.0", - "@turf/line-overlap": "^6.5.0", - "@turf/line-segment": "^6.5.0", - "@turf/line-slice": "^6.5.0", - "@turf/line-slice-along": "^6.5.0", - "@turf/line-split": "^6.5.0", - "@turf/line-to-polygon": "^6.5.0", - "@turf/mask": "^6.5.0", - "@turf/meta": "^6.5.0", - "@turf/midpoint": "^6.5.0", - "@turf/moran-index": "^6.5.0", - "@turf/nearest-point": "^6.5.0", - "@turf/nearest-point-on-line": "^6.5.0", - "@turf/nearest-point-to-line": "^6.5.0", - "@turf/planepoint": "^6.5.0", - "@turf/point-grid": "^6.5.0", - "@turf/point-on-feature": "^6.5.0", - "@turf/point-to-line-distance": "^6.5.0", - "@turf/points-within-polygon": "^6.5.0", - "@turf/polygon-smooth": "^6.5.0", - "@turf/polygon-tangents": "^6.5.0", - "@turf/polygon-to-line": "^6.5.0", - "@turf/polygonize": "^6.5.0", - "@turf/projection": "^6.5.0", - "@turf/random": "^6.5.0", - "@turf/rewind": "^6.5.0", - "@turf/rhumb-bearing": "^6.5.0", - "@turf/rhumb-destination": "^6.5.0", - "@turf/rhumb-distance": "^6.5.0", - "@turf/sample": "^6.5.0", - "@turf/sector": "^6.5.0", - "@turf/shortest-path": "^6.5.0", - "@turf/simplify": "^6.5.0", - "@turf/square": "^6.5.0", - "@turf/square-grid": "^6.5.0", - "@turf/standard-deviational-ellipse": "^6.5.0", - "@turf/tag": "^6.5.0", - "@turf/tesselate": "^6.5.0", - "@turf/tin": "^6.5.0", - "@turf/transform-rotate": "^6.5.0", - "@turf/transform-scale": "^6.5.0", - "@turf/transform-translate": "^6.5.0", - "@turf/triangle-grid": "^6.5.0", - "@turf/truncate": "^6.5.0", - "@turf/union": "^6.5.0", - "@turf/unkink-polygon": "^6.5.0", - "@turf/voronoi": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/union": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/union/-/union-6.5.0.tgz", - "integrity": "sha512-igYWCwP/f0RFHIlC2c0SKDuM/ObBaqSljI3IdV/x71805QbIvY/BYGcJdyNcgEA6cylIGl/0VSlIbpJHZ9ldhw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "polygon-clipping": "^0.15.3" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/unkink-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-6.5.0.tgz", - "integrity": "sha512-8QswkzC0UqKmN1DT6HpA9upfa1HdAA5n6bbuzHy8NJOX8oVizVAqfEPY0wqqTgboDjmBR4yyImsdPGUl3gZ8JQ==", - "dependencies": { - "@turf/area": "^6.5.0", - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0", - "rbush": "^2.0.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/voronoi": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-6.5.0.tgz", - "integrity": "sha512-C/xUsywYX+7h1UyNqnydHXiun4UPjK88VDghtoRypR9cLlb7qozkiLRphQxxsCM0KxyxpVPHBVQXdAL3+Yurow==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "d3-voronoi": "1.1.2" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", - "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==" - }, - "node_modules/@types/babel__core": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", - "integrity": "sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz", - "integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==", - "dependencies": { - "@babel/types": "^7.3.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/classnames": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz", - "integrity": "sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==", - "deprecated": "This is a stub types definition. classnames provides its own type definitions, so you do not need this installed.", - "dependencies": { - "classnames": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, - "node_modules/@types/eslint": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz", - "integrity": "sha512-rc9K8ZpVjNcLs8Fp0dkozd5Pt2Apk1glO4Vgz8ix1u6yFByxfqo5Yavpy65o+93TAe24jr7v+eSBtFLvOQtCRQ==", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/geojson": { - "version": "7946.0.10", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", - "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==" - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", - "integrity": "sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.14", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", - "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" - }, - "node_modules/@types/mapbox-gl": { - "version": "2.7.10", - "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-2.7.10.tgz", - "integrity": "sha512-nMVEcu9bAcenvx6oPWubQSPevsekByjOfKjlkr+8P91vawtkxTnopDoXXq1Qn/f4cg3zt0Z2W9DVsVsKRNXJTw==", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "node_modules/@types/node": { - "version": "20.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.3.tgz", - "integrity": "sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==" - }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "node_modules/@types/prettier": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", - "integrity": "sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "license": "MIT" - }, - "node_modules/@types/q": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", - "integrity": "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw==" - }, - "node_modules/@types/qs": { - "version": "6.9.11", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", - "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==" - }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "node_modules/@types/react": { - "version": "17.0.65", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.65.tgz", - "integrity": "sha512-oxur785xZYHvnI7TRS61dXbkIhDPnGfsXKv0cNXR/0ml4SipRIFpSMzA7HMEfOywFwJ5AOnPrXYTEiTRUQeGlQ==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", - "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", - "devOptional": true, - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, - "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==" - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dependencies": { - "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" - }, - "node_modules/@types/warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", - "integrity": "sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA==" - }, - "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.5.tgz", - "integrity": "sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", - "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", - "integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==", - "dependencies": { - "@typescript-eslint/utils": "5.62.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", - "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", - "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.34.0", - "@typescript-eslint/types": "^8.34.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", - "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", - "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", - "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opt": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "engines": [ - "node >= 0.8.0" - ], - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", - "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.reduce": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.6.tgz", - "integrity": "sha512-UW+Mz8LG/sPSU8jRDCjVr6J/ZKAGpHfwrZ6kWTG5qCxIEiXdVshqGnu5vEZA8S1y6X4aCSbQZ0/EEsfvEvBiSg==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-array-method-boxes-properly": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==" - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/babel-loader": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", - "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", - "dependencies": { - "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", - "make-dir": "^3.1.0", - "schema-utils": "^2.6.5" - }, - "engines": { - "node": ">= 8.9" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "webpack": ">=2" - } - }, - "node_modules/babel-loader/node_modules/schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", - "dependencies": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/babel-plugin-named-asset-import": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", - "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", - "peerDependencies": { - "@babel/core": "^7.1.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", - "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.4" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-transform-react-remove-prop-types": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", - "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==" - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-react-app": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", - "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", - "dependencies": { - "@babel/core": "^7.16.0", - "@babel/plugin-proposal-class-properties": "^7.16.0", - "@babel/plugin-proposal-decorators": "^7.16.4", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", - "@babel/plugin-proposal-numeric-separator": "^7.16.0", - "@babel/plugin-proposal-optional-chaining": "^7.16.0", - "@babel/plugin-proposal-private-methods": "^7.16.0", - "@babel/plugin-transform-flow-strip-types": "^7.16.0", - "@babel/plugin-transform-react-display-name": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.4", - "@babel/preset-env": "^7.16.4", - "@babel/preset-react": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", - "@babel/runtime": "^7.16.3", - "babel-plugin-macros": "^3.1.0", - "babel-plugin-transform-react-remove-prop-types": "^0.4.24" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" - }, - "node_modules/bfj": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", - "integrity": "sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==", - "dependencies": { - "bluebird": "^3.7.2", - "check-types": "^11.2.3", - "hoopy": "^0.1.4", - "jsonpath": "^1.1.1", - "tryer": "^1.0.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/bonjour-service": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", - "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" - }, - "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "dependencies": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001720", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", - "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/canvg": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", - "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/case-sensitive-paths-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "engines": { - "node": ">=10" - } - }, - "node_modules/check-types": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", - "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==" - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/ci-info": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", - "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/classnames": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", - "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" - }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clean-css/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/coa": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", - "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", - "dependencies": { - "@types/q": "^1.5.1", - "chalk": "^2.4.1", - "q": "^1.1.2" - }, - "engines": { - "node": ">= 4.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" - }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/concaveman": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/concaveman/-/concaveman-1.2.1.tgz", - "integrity": "sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw==", - "dependencies": { - "point-in-polygon": "^1.1.0", - "rbush": "^3.0.1", - "robust-predicates": "^2.0.4", - "tinyqueue": "^2.0.3" - } - }, - "node_modules/concaveman/node_modules/rbush": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", - "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", - "dependencies": { - "quickselect": "^2.0.0" - } - }, - "node_modules/confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==" - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", - "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", - "dependencies": { - "safe-buffer": "~5.1.1" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/core-js": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz", - "integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", - "dependencies": { - "browserslist": "^4.24.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-pure": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.35.0.tgz", - "integrity": "sha512-f+eRYmkou59uh7BPcyJ8MC76DiGhspj1KMxVIcF24tzP8NA9HVa1uC7BTW2tgx7E1QVCzDzsgp7kArrzhlz8Ew==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/cosmiconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", - "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dependencies": { - "node-fetch": "2.6.7" - } - }, - "node_modules/cross-fetch/node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/cross-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/cross-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/cross-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/css-blank-pseudo": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", - "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "bin": { - "css-blank-pseudo": "dist/cli.cjs" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-box-model": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", - "license": "MIT", - "dependencies": { - "tiny-invariant": "^1.0.6" - } - }, - "node_modules/css-declaration-sorter": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", - "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/css-has-pseudo": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", - "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", - "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "bin": { - "css-has-pseudo": "dist/cli.cjs" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/css-loader": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.9.0.tgz", - "integrity": "sha512-3I5Nu4ytWlHvOP6zItjiHlefBNtrH+oehq8tnQa2kO305qpVyx9XNIT1CXIj5bgCJs7qICBCkgCYxQLKPANoLA==", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.31", - "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.3", - "postcss-modules-scope": "^3.1.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/css-minimizer-webpack-plugin": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", - "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", - "dependencies": { - "cssnano": "^5.0.6", - "jest-worker": "^27.0.2", - "postcss": "^8.3.5", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@parcel/css": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/css-prefers-color-scheme": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", - "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", - "bin": { - "css-prefers-color-scheme": "dist/cli.cjs" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", - "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^3.2.1", - "domutils": "^1.7.0", - "nth-check": "^1.0.2" - } - }, - "node_modules/css-select-base-adapter": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", - "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" - }, - "node_modules/css-tree": { - "version": "1.0.0-alpha.37", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", - "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", - "dependencies": { - "mdn-data": "2.0.4", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/css-tree/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/css-vendor": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", - "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "dependencies": { - "@babel/runtime": "^7.8.3", - "is-in-browser": "^1.0.2" - } - }, - "node_modules/css-what": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", - "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true - }, - "node_modules/csscolorparser": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", - "integrity": "sha1-s085HupNqPPpgjHizNjfnAQfFxs=" - }, - "node_modules/cssdb": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.10.0.tgz", - "integrity": "sha512-yGZ5tmA57gWh/uvdQBHs45wwFY0IBh3ypABk5sEubPBPSzXzkNgsWReqx7gdx6uhC+QoFBe+V8JwBB9/hQ6cIA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - } - ] - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssfontparser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", - "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", - "dev": true - }, - "node_modules/cssnano": { - "version": "5.1.15", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", - "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", - "dependencies": { - "cssnano-preset-default": "^5.2.14", - "lilconfig": "^2.0.3", - "yaml": "^1.10.2" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/cssnano-preset-default": { - "version": "5.2.14", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", - "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", - "dependencies": { - "css-declaration-sorter": "^6.3.1", - "cssnano-utils": "^3.1.0", - "postcss-calc": "^8.2.3", - "postcss-colormin": "^5.3.1", - "postcss-convert-values": "^5.1.3", - "postcss-discard-comments": "^5.1.2", - "postcss-discard-duplicates": "^5.1.0", - "postcss-discard-empty": "^5.1.1", - "postcss-discard-overridden": "^5.1.0", - "postcss-merge-longhand": "^5.1.7", - "postcss-merge-rules": "^5.1.4", - "postcss-minify-font-values": "^5.1.0", - "postcss-minify-gradients": "^5.1.1", - "postcss-minify-params": "^5.1.4", - "postcss-minify-selectors": "^5.2.1", - "postcss-normalize-charset": "^5.1.0", - "postcss-normalize-display-values": "^5.1.0", - "postcss-normalize-positions": "^5.1.1", - "postcss-normalize-repeat-style": "^5.1.1", - "postcss-normalize-string": "^5.1.0", - "postcss-normalize-timing-functions": "^5.1.0", - "postcss-normalize-unicode": "^5.1.1", - "postcss-normalize-url": "^5.1.0", - "postcss-normalize-whitespace": "^5.1.1", - "postcss-ordered-values": "^5.1.3", - "postcss-reduce-initial": "^5.1.2", - "postcss-reduce-transforms": "^5.1.0", - "postcss-svgo": "^5.1.0", - "postcss-unique-selectors": "^5.1.1" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/cssnano-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", - "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/csso": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", - "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", - "dependencies": { - "css-tree": "^1.1.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", - "dependencies": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "node_modules/csso/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/cv-manager": { - "resolved": "", - "link": true - }, - "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", - "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", - "dependencies": { - "d3-array": "1" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale/node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time/node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-voronoi": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", - "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" - }, - "node_modules/data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", - "dependencies": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/date-arithmetic": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", - "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==" - }, - "node_modules/date-fns": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.2.0.tgz", - "integrity": "sha512-E4KWKavANzeuusPi0jUjpuI22SURAznGkx7eZV+4i6x2A+IZxAMcajgkvuDAU1bg40+xuhW1zRdVIIM/4khuIg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/date-fns-tz": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", - "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", - "license": "MIT", - "peerDependencies": { - "date-fns": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/dayjs": { - "version": "1.11.7", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", - "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" - }, - "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/density-clustering": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/density-clustering/-/density-clustering-1.3.0.tgz", - "integrity": "sha512-icpmBubVTwLnsaor9qH/4tG5+7+f61VcqMN3V3pm9sxxSCt2Jcs0zWOgwZW9ARJYaKD3FumIgHiMOcIMRRAzFQ==" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" - }, - "node_modules/detect-port-alt": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", - "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", - "dependencies": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "bin": { - "detect": "bin/detect-port", - "detect-port": "bin/detect-port" - }, - "engines": { - "node": ">= 4.2.1" - } - }, - "node_modules/detect-port-alt/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/detect-port-alt/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "dependencies": { - "utila": "~0.4" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "dependencies": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - } - }, - "node_modules/dom-serializer/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" - }, - "node_modules/domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", - "dependencies": { - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domhandler/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true - }, - "node_modules/domutils": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", - "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", - "dependencies": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", - "engines": { - "node": ">=10" - } - }, - "node_modules/dotenv-expand": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", - "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" - }, - "node_modules/earcut": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", - "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.161", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", - "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", - "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-cmd": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", - "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==", - "dependencies": { - "commander": "^4.0.0", - "cross-spawn": "^7.0.0" - }, - "bin": { - "env-cmd": "bin/env-cmd.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dependencies": { - "hasown": "^2.0.0" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-react-app": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", - "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.16.0", - "@babel/eslint-parser": "^7.16.3", - "@rushstack/eslint-patch": "^1.1.0", - "@typescript-eslint/eslint-plugin": "^5.5.0", - "@typescript-eslint/parser": "^5.5.0", - "babel-preset-react-app": "^10.0.1", - "confusing-browser-globals": "^1.0.11", - "eslint-plugin-flowtype": "^8.0.3", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jest": "^25.3.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.27.1", - "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-testing-library": "^5.0.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "eslint": "^8.0.0" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-flowtype": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", - "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", - "dependencies": { - "lodash": "^4.17.21", - "string-natural-compare": "^3.0.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@babel/plugin-syntax-flow": "^7.14.5", - "@babel/plugin-transform-react-jsx": "^7.14.9", - "eslint": "^8.1.0" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", - "semver": "^6.3.1", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jest": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", - "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", - "dependencies": { - "@typescript-eslint/experimental-utils": "^5.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", - "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", - "dependencies": { - "@babel/runtime": "^7.23.2", - "aria-query": "^5.3.0", - "array-includes": "^3.1.7", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "=4.7.0", - "axobject-query": "^3.2.1", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.15", - "hasown": "^2.0.0", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-testing-library": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.1.tgz", - "integrity": "sha512-5eX9e1Kc2PqVRed3taaLnAAqPZGEX75C+M/rXzUAI3wIg/ZxzUm1OVAwfe/O+vE+6YXOLetSe9g5GKD2ecXipw==", - "dependencies": { - "@typescript-eslint/utils": "^5.58.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0", - "npm": ">=6" - }, - "peerDependencies": { - "eslint": "^7.5.0 || ^8.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-webpack-plugin": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", - "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", - "dependencies": { - "@types/eslint": "^7.29.0 || ^8.4.1", - "jest-worker": "^28.0.2", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0", - "webpack": "^5.0.0" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", - "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/express/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" ] }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-equals": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", - "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/fastq": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", - "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", - "dependencies": { - "reusify": "^1.0.4" - } + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dependencies": { - "bser": "2.1.1" - } + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dependencies": { - "minimatch": "^5.0.1" - } + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", - "engines": { - "node": ">= 0.4.0" - } + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" - }, - "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node_modules/@stomp/stompjs": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.2.1.tgz", + "integrity": "sha512-DLd/WeicnHS5SsWWSk3x6/pcivqchNaEvg9UEGVqAcfYEBVmS9D6980ckXjTtfpXLjdLDsd96M7IuX4w7nzq5g==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" } }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", - "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" }, "engines": { - "node": ">=10", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "eslint": ">= 6", - "typescript": ">= 2.7", - "vue-template-compiler": "*", - "webpack": ">= 4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "vue-template-compiler": { - "optional": true - } + "node": ">=12" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "license": "Apache-2.0", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "deep-equal": "^2.0.5" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@testing-library/dom/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT" }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/@testing-library/dom/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@testing-library/react": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "node": ">=12" + }, + "peerDependencies": { + "react": "<18.0.0", + "react-dom": "<18.0.0" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "node_modules/@turf/along": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", + "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/turf" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@turf/angle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-6.5.0.tgz", + "integrity": "sha512-4pXMbWhFofJJAOvTMCns6N4C8CMd5Ih4O2jSAG9b3dDHakj3O4yN1+Zbm+NUei+eVEZ9gFeVp9svE3aMDenIkw==", + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@turf/bearing": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "engines": { - "node": ">=6" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "node_modules/@turf/area": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", + "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", + "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/formik": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", - "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], + "node_modules/@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "license": "MIT", "dependencies": { - "@types/hoist-non-react-statics": "^3.3.1", - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "peerDependencies": { - "react": ">=16.8.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/formik/node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "engines": { - "node": ">=0.10.0" + "node_modules/@turf/bbox-clip": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-6.5.0.tgz", + "integrity": "sha512-F6PaIRF8WMp8EmgU/Ke5B1Y6/pia14UAYB5TiBC668w5rVVjy5L8rTm/m2lEkkDMHlzoP9vNY4pxpNthE7rLcQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/formik/node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" + "node_modules/@turf/bbox-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-6.5.0.tgz", + "integrity": "sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" + "node_modules/@turf/bearing": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz", + "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" + "url": "https://opencollective.com/turf" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" + "node_modules/@turf/bezier-spline": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-6.5.0.tgz", + "integrity": "sha512-vokPaurTd4PF96rRgGVm6zYYC5r1u98ZsG+wZEv9y3kJTuJRX/O3xIY2QnTGTdbVmAJN1ouOsD0RoZYaVoXORQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/@turf/boolean-clockwise": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-6.5.0.tgz", + "integrity": "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==", + "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/fs-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", - "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node_modules/@turf/boolean-contains": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz", + "integrity": "sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/@turf/boolean-crosses": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-6.5.0.tgz", + "integrity": "sha512-gvshbTPhAHporTlQwBJqyfW+2yV8q/mOTxG6PzRVl6ARsqNoqYQWkd4MLug7OmAqVyBzLK3201uAeBjxbGw0Ng==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/@turf/boolean-disjoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-6.5.0.tgz", + "integrity": "sha512-rZ2ozlrRLIAGo2bjQ/ZUu4oZ/+ZjGvLkN5CKXSKBcu6xFO6k2bgqeM8a1836tAW+Pqp/ZFsTA5fZHsJZvP2D5g==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "node_modules/@turf/boolean-equal": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz", + "integrity": "sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "geojson-equality": "0.1.6" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "engines": { - "node": ">=6.9.0" + "node_modules/@turf/boolean-intersects": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-6.5.0.tgz", + "integrity": "sha512-nIxkizjRdjKCYFQMnml6cjPsDOBCThrt+nkqtSEcxkKMhAQj5OO7o2CecioNTaX8EayqwMGVKcsz27oP4mKPTw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-disjoint": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/geojson": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", - "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "node_modules/@turf/boolean-overlap": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-6.5.0.tgz", + "integrity": "sha512-8btMIdnbXVWUa1M7D4shyaSGxLRw6NjMcqKBcsTXcZdnaixl22k7ar7BvIzkaRYN3SFECk9VGXfLncNS3ckQUw==", "license": "MIT", - "engines": { - "node": ">= 0.10" + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-overlap": "^6.5.0", + "@turf/meta": "^6.5.0", + "geojson-equality": "0.1.6" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/geojson-equality": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", - "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", + "node_modules/@turf/boolean-parallel": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-6.5.0.tgz", + "integrity": "sha512-aSHJsr1nq9e5TthZGZ9CZYeXklJyRgR5kCLm5X4urz7+MotMOp/LsGOsvKvK9NeUl9+8OUmfMn8EFTT8LkcvIQ==", + "license": "MIT", "dependencies": { - "deep-equal": "^1.0.0" + "@turf/clean-coords": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/geojson-rbush": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz", - "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==", + "node_modules/@turf/boolean-point-in-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", + "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "license": "MIT", "dependencies": { - "@turf/bbox": "*", - "@turf/helpers": "6.x", - "@turf/meta": "6.x", - "@types/geojson": "7946.0.8", - "rbush": "^3.0.1" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/geojson-rbush/node_modules/@types/geojson": { - "version": "7946.0.8", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", - "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" - }, - "node_modules/geojson-rbush/node_modules/rbush": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", - "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "node_modules/@turf/boolean-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz", + "integrity": "sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ==", + "license": "MIT", "dependencies": { - "quickselect": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/geojson-vt": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", - "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "node_modules/@turf/boolean-within": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-6.5.0.tgz", + "integrity": "sha512-YQB3oU18Inx35C/LU930D36RAVe7LDXk1kWsQ8mLmuqYn9YdPsDQTMTkLJMhoQ8EbN7QTdy333xRQ4MYgToteQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/@turf/buffer": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-6.5.0.tgz", + "integrity": "sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/bbox": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/projection": "^6.5.0", + "d3-geo": "1.7.1", + "turf-jsts": "*" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "engines": { - "node": ">=8.0.0" + "node_modules/@turf/center": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center/-/center-6.5.0.tgz", + "integrity": "sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/@turf/center-mean": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-6.5.0.tgz", + "integrity": "sha512-AAX6f4bVn12pTVrMUiB9KrnV94BgeBKpyg3YpfnEbBpkN/znfVhL8dG8IxMAxAoSZ61Zt9WLY34HfENveuOZ7Q==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" + "node_modules/@turf/center-median": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-6.5.0.tgz", + "integrity": "sha512-dT8Ndu5CiZkPrj15PBvslpuf01ky41DEYEPxS01LOxp5HOUHXp1oJxsPxvc+i/wK4BwccPNzU1vzJ0S4emd1KQ==", + "license": "MIT", + "dependencies": { + "@turf/center-mean": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/@turf/center-of-mass": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz", + "integrity": "sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "@turf/centroid": "^6.5.0", + "@turf/convex": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/gl-matrix": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", - "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/@turf/centroid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", + "integrity": "sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/turf" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/@turf/circle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-6.5.0.tgz", + "integrity": "sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "@turf/destination": "^6.5.0", + "@turf/helpers": "^6.5.0" }, - "engines": { - "node": ">=10.13.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" - }, - "node_modules/global-modules": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "node_modules/@turf/clean-coords": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", + "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", + "license": "MIT", "dependencies": { - "global-prefix": "^3.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/global-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "node_modules/@turf/clone": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-6.5.0.tgz", + "integrity": "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==", + "license": "MIT", "dependencies": { - "ini": "^1.3.5", - "kind-of": "^6.0.2", - "which": "^1.3.1" + "@turf/helpers": "^6.5.0" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/@turf/clusters": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-6.5.0.tgz", + "integrity": "sha512-Y6gfnTJzQ1hdLfCsyd5zApNbfLIxYEpmDibHUqR5z03Lpe02pa78JtgrgUNt1seeO/aJ4TG1NLN8V5gOrHk04g==", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "bin": { - "which": "bin/which" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/@turf/clusters-dbscan": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-6.5.0.tgz", + "integrity": "sha512-SxZEE4kADU9DqLRiT53QZBBhu8EP9skviSyl+FGj08Y01xfICM/RR9ACUdM0aEQimhpu+ZpRVcUK+2jtiCGrYQ==", "license": "MIT", "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "density-clustering": "1.3.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/@turf/clusters-kmeans": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-6.5.0.tgz", + "integrity": "sha512-DwacD5+YO8kwDPKaXwT9DV46tMBVNsbi1IzdajZu1JDSWoN7yc7N9Qt88oi+p30583O0UPVkAK+A10WAQv4mUw==", + "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "skmeans": "0.9.7" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/@turf/collect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-6.5.0.tgz", + "integrity": "sha512-4dN/T6LNnRg099m97BJeOcTA5fSI8cu87Ydgfibewd2KQwBexO69AnjEFqfPX3Wj+Zvisj1uAVIZbPmSSrZkjg==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "rbush": "2.x" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" - }, - "node_modules/grid-index": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", - "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==" - }, - "node_modules/growly": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", - "optional": true, - "peer": true - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "node_modules/@turf/combine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-6.5.0.tgz", + "integrity": "sha512-Q8EIC4OtAcHiJB3C4R+FpB4LANiT90t17uOd851qkM2/o6m39bfN5Mv0PWqMZIHWrrosZqRqoY9dJnzz/rJxYQ==", + "license": "MIT", "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" - }, - "node_modules/harmony-reflect": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", - "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/@turf/concave": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-6.5.0.tgz", + "integrity": "sha512-I/sUmUC8TC5h/E2vPwxVht+nRt+TnXIPRoztDFvS8/Y0+cBDple9inLSo9nnPXMXidrBlGXZ9vQx/BjZUJgsRQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/tin": "^6.5.0", + "topojson-client": "3.x", + "topojson-server": "3.x" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "engines": { - "node": ">=4" + "url": "https://opencollective.com/turf" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/@turf/convex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-6.5.0.tgz", + "integrity": "sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg==", + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "concaveman": "*" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/@turf/destination": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz", + "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/@turf/difference": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-6.5.0.tgz", + "integrity": "sha512-l8iR5uJqvI+5Fs6leNbhPY5t/a3vipUF/3AeVLpwPQcgmedNXyheYuy07PcMGH5Jdpi5gItOiTqwiU/bUH4b3A==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/@turf/dissolve": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-6.5.0.tgz", + "integrity": "sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "polygon-clipping": "^0.15.3" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/@turf/distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", + "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "bin": { - "he": "bin/he" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/history": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", - "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "node_modules/@turf/distance-weight": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-6.5.0.tgz", + "integrity": "sha512-a8qBKkgVNvPKBfZfEJZnC3DV7dfIsC3UIdpRci/iap/wZLH41EmS90nM+BokAJflUHYy8PqE44wySGWHN1FXrQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.7.6" + "@turf/centroid": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "node_modules/@turf/ellipse": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-6.5.0.tgz", + "integrity": "sha512-kuXtwFviw/JqnyJXF1mrR/cb496zDTSbGKtSiolWMNImYzGGkbsAsFTjwJYgD7+4FixHjp0uQPzo70KDf3AIBw==", + "license": "MIT", "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoopy": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", - "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", - "engines": { - "node": ">= 6.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/transform-rotate": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "node_modules/@turf/envelope": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-6.5.0.tgz", + "integrity": "sha512-9Z+FnBWvOGOU4X+fMZxYFs1HjFlkKqsddLuMknRaqcJd6t+NIv5DWvPtDL8ATD2GEExYDiFLwMdckfr1yqJgHA==", + "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" + "@turf/bbox": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/hpack.js/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/@turf/explode": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-6.5.0.tgz", + "integrity": "sha512-6cSvMrnHm2qAsace6pw9cDmK2buAlw8+tjeJVXMfMyY+w7ZUi1rprWMsY92J7s2Dar63Bv09n56/1V7+tcj52Q==", + "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/@turf/flatten": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-6.5.0.tgz", + "integrity": "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==", + "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "node_modules/@turf/flip": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-6.5.0.tgz", + "integrity": "sha512-oyikJFNjt2LmIXQqgOGLvt70RgE2lyzPMloYWM7OR5oIFGRiBvqVD2hA6MNw6JewIm30fWZ8DQJw1NHXJTJPbg==", + "license": "MIT", "dependencies": { - "whatwg-encoding": "^1.0.5" + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ] - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" - }, - "node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "node_modules/@turf/great-circle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-6.5.0.tgz", + "integrity": "sha512-7ovyi3HaKOXdFyN7yy1yOMa8IyOvV46RC1QOQTT+RYUN8ke10eyqExwBpL9RFUPvlpoTzoYbM/+lWPogQlFncg==", + "license": "MIT", "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/html-minifier-terser/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "engines": { - "node": ">= 12" + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/html-to-image": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", - "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==", - "license": "MIT" - }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "node_modules/@turf/hex-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-6.5.0.tgz", + "integrity": "sha512-Ln3tc2tgZT8etDOldgc6e741Smg1CsMKAz1/Mlel+MEL5Ynv2mhx3m0q4J9IB1F3a4MNjDeVvm8drAaf9SF33g==", "license": "MIT", - "optional": true, "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/intersect": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">=8.0.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], + "node_modules/@turf/interpolate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-6.5.0.tgz", + "integrity": "sha512-LSH5fMeiGyuDZ4WrDJNgh81d2DnNDUVJtuFryJFup8PV8jbs46lQGfI3r1DJ2p1IlEJIz3pmAZYeTfMMoeeohw==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" + "@turf/bbox": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/hex-grid": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/point-grid": "^6.5.0", + "@turf/square-grid": "^6.5.0", + "@turf/triangle-grid": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/htmlparser2/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "node_modules/@turf/intersect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-6.5.0.tgz", + "integrity": "sha512-2legGJeKrfFkzntcd4GouPugoqPUjexPZnOvfez+3SfIMrHvulw8qV8u7pfVyn2Yqs53yoVCEjS5sEpvQ5YRQg==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" }, "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/htmlparser2/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/htmlparser2/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "license": "MIT", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/@turf/isobands": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-6.5.0.tgz", + "integrity": "sha512-4h6sjBPhRwMVuFaVBv70YB7eGz+iw0bhPRnp+8JBdX1UPJSXhoi/ZF2rACemRUr0HkdVB/a1r9gC32vn5IAEkw==", + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "@turf/area": "^6.5.0", + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "object-assign": "*" }, - "engines": { - "node": ">= 0.8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "node_modules/@turf/isolines": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-6.5.0.tgz", + "integrity": "sha512-6ElhiLCopxWlv4tPoxiCzASWt/jMRvmp6mRYrpzOm3EUl75OhHKa/Pu6Y9nWtCMmVC/RcWtiiweUocbPLZLm0A==", + "license": "MIT", "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "object-assign": "*" }, - "engines": { - "node": ">=8.0.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "node_modules/@turf/kinks": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-6.5.0.tgz", + "integrity": "sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ==", + "license": "MIT", "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "@turf/helpers": "^6.5.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "node_modules/@turf/length": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz", + "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==", + "license": "MIT", "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/@turf/line-arc": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-6.5.0.tgz", + "integrity": "sha512-I6c+V6mIyEwbtg9P9zSFF89T7QPe1DPTG3MJJ6Cm1MrAY0MdejwQKOpsvNl8LDU2ekHOlz2kHpPVR7VJsoMllA==", + "license": "MIT", "dependencies": { - "agent-base": "6", - "debug": "4" + "@turf/circle": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/helpers": "^6.5.0" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "engines": { - "node": ">=10.17.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/@turf/line-chunk": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-6.5.0.tgz", + "integrity": "sha512-i1FGE6YJaaYa+IJesTfyRRQZP31QouS+wh/pa6O3CC0q4T7LtHigyBSYjrbjSLfn2EVPYGlPCMFEqNWCOkC6zg==", + "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "@turf/helpers": "^6.5.0", + "@turf/length": "^6.5.0", + "@turf/line-slice-along": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "engines": { - "node": "^10 || ^12 || >= 14" + "node_modules/@turf/line-intersect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz", + "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "geojson-rbush": "3.x" }, - "peerDependencies": { - "postcss": "^8.1.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" - }, - "node_modules/identity-obj-proxy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", - "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "node_modules/@turf/line-offset": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-6.5.0.tgz", + "integrity": "sha512-CEXZbKgyz8r72qRvPchK0dxqsq8IQBdH275FE6o4MrBkzMcoZsfSjghtXzKaz9vvro+HfIXal0sTk2mqV1lQTw==", + "license": "MIT", "dependencies": { - "harmony-reflect": "^1.4.6" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", - "engines": { - "node": ">= 4" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" - }, - "node_modules/immer": { - "version": "9.0.17", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.17.tgz", - "integrity": "sha512-+hBruaLSQvkPfxRiTLK/mi4vLH+/VQS6z2KJahdoxlleFOI8ARqzOF17uy12eFDlqWmPoygwc5evgwcp+dlHhg==", + "node_modules/@turf/line-overlap": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-6.5.0.tgz", + "integrity": "sha512-xHOaWLd0hkaC/1OLcStCpfq55lPHpPNadZySDXYiYjEz5HXr1oKmtMYpn0wGizsLwrOixRdEp+j7bL8dPt4ojQ==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "deep-equal": "1.x", + "geojson-rbush": "3.x" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" + "url": "https://opencollective.com/turf" } }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "node_modules/@turf/line-segment": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz", + "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==", + "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "node_modules/@turf/line-slice": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-6.5.0.tgz", + "integrity": "sha512-vDqJxve9tBHhOaVVFXqVjF5qDzGtKWviyjbyi2QnSnxyFAmLlLnBfMX8TLQCAf2GxHibB95RO5FBE6I2KVPRuw==", + "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" + "url": "https://opencollective.com/turf" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "node_modules/@turf/line-slice-along": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-6.5.0.tgz", + "integrity": "sha512-KHJRU6KpHrAj+BTgTNqby6VCTnDzG6a1sJx/I3hNvqMBLvWVA2IrkR9L9DtsQsVY63IBwVdQDqiwCuZLDQh4Ng==", + "license": "MIT", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/@turf/line-split": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-6.5.0.tgz", + "integrity": "sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "@turf/square": "^6.5.0", + "@turf/truncate": "^6.5.0", + "geojson-rbush": "3.x" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "engines": { - "node": ">=12" + "node_modules/@turf/line-to-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-6.5.0.tgz", + "integrity": "sha512-qYBuRCJJL8Gx27OwCD1TMijM/9XjRgXH/m/TyuND4OXedBpIWlK5VbTIO2gJ8OCfznBBddpjiObLBrkuxTpN4Q==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "node_modules/@turf/mask": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-6.5.0.tgz", + "integrity": "sha512-RQha4aU8LpBrmrkH8CPaaoAfk0Egj5OuXtv6HuCQnHeGNOQt3TQVibTA3Sh4iduq4EPxnZfDjgsOeKtrCA19lg==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.0.0" + "@turf/helpers": "^6.5.0", + "polygon-clipping": "^0.15.3" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", - "engines": { - "node": ">= 10" + "node_modules/@turf/meta": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", + "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "node_modules/@turf/midpoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-6.5.0.tgz", + "integrity": "sha512-MyTzV44IwmVI6ec9fB2OgZ53JGNlgOpaYl9ArKoF49rXpL84F9rNATndbe0+MQIhdkw8IlzA6xVP4lZzfMNVCw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/@turf/moran-index": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-6.5.0.tgz", + "integrity": "sha512-ItsnhrU2XYtTtTudrM8so4afBCYWNaB0Mfy28NZwLjB5jWuAsvyV+YW+J88+neK/ougKMTawkmjQqodNJaBeLQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "@turf/distance-weight": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/@turf/nearest-point": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-6.5.0.tgz", + "integrity": "sha512-fguV09QxilZv/p94s8SMsXILIAMiaXI5PATq9d7YWijLxWUj6Q/r43kxyoi78Zmwwh1Zfqz9w+bCYUAxZ5+euA==", "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/@turf/nearest-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz", + "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==", "license": "MIT", "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/@turf/nearest-point-to-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-6.5.0.tgz", + "integrity": "sha512-PXV7cN0BVzUZdjj6oeb/ESnzXSfWmEMrsfZSDRgqyZ9ytdiIj/eRsnOXLR13LkTdXVOJYDBuf7xt1mLhM4p6+Q==", + "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/point-to-line-distance": "^6.5.0", + "object-assign": "*" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/@turf/planepoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-6.5.0.tgz", + "integrity": "sha512-R3AahA6DUvtFbka1kcJHqZ7DMHmPXDEQpbU5WaglNn7NaCQg9HB0XM0ZfqWcd5u92YXV+Gg8QhC8x5XojfcM4Q==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/@turf/point-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-6.5.0.tgz", + "integrity": "sha512-Iq38lFokNNtQJnOj/RBKmyt6dlof0yhaHEDELaWHuECm1lIZLY3ZbVMwbs+nXkwTAHjKfS/OtMheUBkw+ee49w==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/boolean-within": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "node_modules/@turf/point-on-feature": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-6.5.0.tgz", + "integrity": "sha512-bDpuIlvugJhfcF/0awAQ+QI6Om1Y1FFYE8Y/YdxGRongivix850dTeXCo0mDylFdWFPGDo7Mmh9Vo4VxNwW/TA==", + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/nearest-point": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/@turf/point-to-line-distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-6.5.0.tgz", + "integrity": "sha512-opHVQ4vjUhNBly1bob6RWy+F+hsZDH9SA0UW36pIRzfpu27qipU18xup0XXEePfY6+wvhF6yL/WgCO2IbrLqEA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" + "@turf/bearing": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/projection": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/@turf/points-within-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-6.5.0.tgz", + "integrity": "sha512-YyuheKqjliDsBDt3Ho73QVZk1VXX1+zIA2gwWvuz8bR1HXOkcuwk/1J76HuFMOQI3WK78wyAi+xbkx268PkQzQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" + "node_modules/@turf/polygon-smooth": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-6.5.0.tgz", + "integrity": "sha512-LO/X/5hfh/Rk4EfkDBpLlVwt3i6IXdtQccDT9rMjXEP32tRgy0VMFmdkNaXoGlSSKf/1mGqLl4y4wHd86DqKbg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/@turf/polygon-tangents": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-6.5.0.tgz", + "integrity": "sha512-sB4/IUqJMYRQH9jVBwqS/XDitkEfbyqRy+EH/cMRJURTg78eHunvJ708x5r6umXsbiUyQU4eqgPzEylWEQiunw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@turf/bbox": "^6.5.0", + "@turf/boolean-within": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/nearest-point": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "engines": { - "node": ">=6" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "node_modules/@turf/polygon-to-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-6.5.0.tgz", + "integrity": "sha512-5p4n/ij97EIttAq+ewSnKt0ruvuM+LIDzuczSzuHTpq4oS7Oq8yqg5TQ4nzMVuK41r/tALCk7nAoBuw3Su4Gcw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/@turf/polygonize": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-6.5.0.tgz", + "integrity": "sha512-a/3GzHRaCyzg7tVYHo43QUChCspa99oK4yPqooVIwTC61npFzdrmnywMv0S+WZjHZwK37BrFJGFrZGf6ocmY5w==", + "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/envelope": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/is-in-browser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/@turf/projection": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-6.5.0.tgz", + "integrity": "sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/@turf/random": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/random/-/random-6.5.0.tgz", + "integrity": "sha512-8Q25gQ/XbA7HJAe+eXp4UhcXM9aOOJFaxZ02+XSNwMvY8gtWSCBLVqRcW4OhqilgZ8PeuQDWgBxeo+BIqqFWFQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/@turf/rectangle-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-6.5.0.tgz", + "integrity": "sha512-yQZ/1vbW68O2KsSB3OZYK+72aWz/Adnf7m2CMKcC+aq6TwjxZjAvlbCOsNUnMAuldRUVN1ph6RXMG4e9KEvKvg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/boolean-intersects": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "engines": { - "node": ">=0.10.0" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/@turf/rewind": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-6.5.0.tgz", + "integrity": "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==", "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "engines": { - "node": ">=10" + "dependencies": { + "@turf/boolean-clockwise": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/@turf/rhumb-bearing": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-6.5.0.tgz", + "integrity": "sha512-jMyqiMRK4hzREjQmnLXmkJ+VTNTx1ii8vuqRwJPcTlKbNWfjDz/5JqJlb5NaFDcdMpftWovkW5GevfnuzHnOYA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-root": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", - "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", - "engines": { - "node": ">=6" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/@turf/rhumb-destination": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-6.5.0.tgz", + "integrity": "sha512-RHNP1Oy+7xTTdRrTt375jOZeHceFbjwohPHlr9Hf68VdHHPMAWgAKqiX2YgSWDcvECVmiGaBKWus1Df+N7eE4Q==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/@turf/rhumb-distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-6.5.0.tgz", + "integrity": "sha512-oKp8KFE8E4huC2Z1a1KNcFwjVOqa99isxNOwfo4g3SUABQ6NezjKDDrnvC4yI5YZ3/huDjULLBvhed45xdCrzg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "engines": { - "node": ">=8" + "node_modules/@turf/sample": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-6.5.0.tgz", + "integrity": "sha512-kSdCwY7el15xQjnXYW520heKUrHwRvnzx8ka4eYxX9NFeOxaFITLW2G7UtXb6LJK8mmPXI8Aexv23F2ERqzGFg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/@turf/sector": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-6.5.0.tgz", + "integrity": "sha512-cYUOkgCTWqa23SOJBqxoFAc/yGCUsPRdn/ovbRTn1zNTm/Spmk6hVB84LCKOgHqvSF25i0d2kWqpZDzLDdAPbw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@turf/circle": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-arc": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/@turf/shortest-path": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-6.5.0.tgz", + "integrity": "sha512-4de5+G7+P4hgSoPwn+SO9QSi9HY5NEV/xRJ+cmoFVRwv2CDsuOPDheHKeuIAhKyeKDvPvPt04XYWbac4insJMg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "@turf/bbox": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/clean-coords": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/transform-scale": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/@turf/simplify": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-6.5.0.tgz", + "integrity": "sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" + "@turf/clean-coords": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/@turf/square": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz", + "integrity": "sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/@turf/square-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-6.5.0.tgz", + "integrity": "sha512-mlR0ayUdA+L4c9h7p4k3pX6gPWHNGuZkt2c5II1TJRmhLkW2557d6b/Vjfd1z9OVaajb1HinIs1FMSAPXuuUrA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@turf/helpers": "^6.5.0", + "@turf/rectangle-grid": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/@turf/standard-deviational-ellipse": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-6.5.0.tgz", + "integrity": "sha512-02CAlz8POvGPFK2BKK8uHGUk/LXb0MK459JVjKxLC2yJYieOBTqEbjP0qaWhiBhGzIxSMaqe8WxZ0KvqdnstHA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "@turf/center-mean": "^6.5.0", + "@turf/ellipse": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/points-within-polygon": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/turf" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "node_modules/@turf/tag": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-6.5.0.tgz", + "integrity": "sha512-XwlBvrOV38CQsrNfrxvBaAPBQgXMljeU0DV8ExOyGM7/hvuGHJw3y8kKnQ4lmEQcmcrycjDQhP7JqoRv8vFssg==", + "license": "MIT", "dependencies": { - "is-docker": "^2.0.0" + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "node_modules/@turf/tesselate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-6.5.0.tgz", + "integrity": "sha512-M1HXuyZFCfEIIKkglh/r5L9H3c5QTEsnMBoZOFQiRnGPGmJWcaBissGb7mTFX2+DKE7FNWXh4TDnZlaLABB0dQ==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "@turf/helpers": "^6.5.0", + "earcut": "^2.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "node_modules/@turf/tin": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-6.5.0.tgz", + "integrity": "sha512-YLYikRzKisfwj7+F+Tmyy/LE3d2H7D4kajajIfc9mlik2+esG7IolsX/+oUz1biguDYsG0DUA8kVYXDkobukfg==", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" + "@turf/helpers": "^6.5.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@turf/transform-rotate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-6.5.0.tgz", + "integrity": "sha512-A2Ip1v4246ZmpssxpcL0hhiVBEf4L8lGnSPWTgSv5bWBEoya2fa/0SnFX9xJgP40rMP+ZzRaCN37vLHbv1Guag==", + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/@turf/transform-scale": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-6.5.0.tgz", + "integrity": "sha512-VsATGXC9rYM8qTjbQJ/P7BswKWXHdnSJ35JlV4OsZyHBMxJQHftvmZJsFbOqVtQnIQIzf2OAly6rfzVV9QLr7g==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "@turf/bbox": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" + "node_modules/@turf/transform-translate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-6.5.0.tgz", + "integrity": "sha512-NABLw5VdtJt/9vSstChp93pc6oel4qXEos56RBMsPlYB8hzNTEKYtC146XJvyF4twJeeYS8RVe1u7KhoFwEM5w==", + "license": "MIT", + "dependencies": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/istanbul-reports": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", - "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "node_modules/@turf/triangle-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-6.5.0.tgz", + "integrity": "sha512-2jToUSAS1R1htq4TyLQYPTIsoy6wg3e3BQXjm2rANzw4wPQCXGOxrur1Fy9RtzwqwljlC7DF4tg0OnWr8RjmfA==", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/intersect": "^6.5.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "node_modules/@turf/truncate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", + "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "node_modules/@turf/turf": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-6.5.0.tgz", + "integrity": "sha512-ipMCPnhu59bh92MNt8+pr1VZQhHVuTMHklciQURo54heoxRzt1neNYZOBR6jdL+hNsbDGAECMuIpAutX+a3Y+w==", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" + "@turf/along": "^6.5.0", + "@turf/angle": "^6.5.0", + "@turf/area": "^6.5.0", + "@turf/bbox": "^6.5.0", + "@turf/bbox-clip": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/bearing": "^6.5.0", + "@turf/bezier-spline": "^6.5.0", + "@turf/boolean-clockwise": "^6.5.0", + "@turf/boolean-contains": "^6.5.0", + "@turf/boolean-crosses": "^6.5.0", + "@turf/boolean-disjoint": "^6.5.0", + "@turf/boolean-equal": "^6.5.0", + "@turf/boolean-intersects": "^6.5.0", + "@turf/boolean-overlap": "^6.5.0", + "@turf/boolean-parallel": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/boolean-within": "^6.5.0", + "@turf/buffer": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/center-mean": "^6.5.0", + "@turf/center-median": "^6.5.0", + "@turf/center-of-mass": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/circle": "^6.5.0", + "@turf/clean-coords": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/clusters": "^6.5.0", + "@turf/clusters-dbscan": "^6.5.0", + "@turf/clusters-kmeans": "^6.5.0", + "@turf/collect": "^6.5.0", + "@turf/combine": "^6.5.0", + "@turf/concave": "^6.5.0", + "@turf/convex": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/difference": "^6.5.0", + "@turf/dissolve": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/distance-weight": "^6.5.0", + "@turf/ellipse": "^6.5.0", + "@turf/envelope": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/flatten": "^6.5.0", + "@turf/flip": "^6.5.0", + "@turf/great-circle": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/hex-grid": "^6.5.0", + "@turf/interpolate": "^6.5.0", + "@turf/intersect": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/isobands": "^6.5.0", + "@turf/isolines": "^6.5.0", + "@turf/kinks": "^6.5.0", + "@turf/length": "^6.5.0", + "@turf/line-arc": "^6.5.0", + "@turf/line-chunk": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-offset": "^6.5.0", + "@turf/line-overlap": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/line-slice": "^6.5.0", + "@turf/line-slice-along": "^6.5.0", + "@turf/line-split": "^6.5.0", + "@turf/line-to-polygon": "^6.5.0", + "@turf/mask": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/midpoint": "^6.5.0", + "@turf/moran-index": "^6.5.0", + "@turf/nearest-point": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "@turf/nearest-point-to-line": "^6.5.0", + "@turf/planepoint": "^6.5.0", + "@turf/point-grid": "^6.5.0", + "@turf/point-on-feature": "^6.5.0", + "@turf/point-to-line-distance": "^6.5.0", + "@turf/points-within-polygon": "^6.5.0", + "@turf/polygon-smooth": "^6.5.0", + "@turf/polygon-tangents": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0", + "@turf/polygonize": "^6.5.0", + "@turf/projection": "^6.5.0", + "@turf/random": "^6.5.0", + "@turf/rewind": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0", + "@turf/sample": "^6.5.0", + "@turf/sector": "^6.5.0", + "@turf/shortest-path": "^6.5.0", + "@turf/simplify": "^6.5.0", + "@turf/square": "^6.5.0", + "@turf/square-grid": "^6.5.0", + "@turf/standard-deviational-ellipse": "^6.5.0", + "@turf/tag": "^6.5.0", + "@turf/tesselate": "^6.5.0", + "@turf/tin": "^6.5.0", + "@turf/transform-rotate": "^6.5.0", + "@turf/transform-scale": "^6.5.0", + "@turf/transform-translate": "^6.5.0", + "@turf/triangle-grid": "^6.5.0", + "@turf/truncate": "^6.5.0", + "@turf/union": "^6.5.0", + "@turf/unkink-polygon": "^6.5.0", + "@turf/voronoi": "^6.5.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "url": "https://opencollective.com/turf" } }, - "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "node_modules/@turf/union": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-6.5.0.tgz", + "integrity": "sha512-igYWCwP/f0RFHIlC2c0SKDuM/ObBaqSljI3IdV/x71805QbIvY/BYGcJdyNcgEA6cylIGl/0VSlIbpJHZ9ldhw==", + "license": "MIT", "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/jake/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@turf/unkink-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-6.5.0.tgz", + "integrity": "sha512-8QswkzC0UqKmN1DT6HpA9upfa1HdAA5n6bbuzHy8NJOX8oVizVAqfEPY0wqqTgboDjmBR4yyImsdPGUl3gZ8JQ==", + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "@turf/area": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "rbush": "^2.0.1" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/jake/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@turf/voronoi": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-6.5.0.tgz", + "integrity": "sha512-C/xUsywYX+7h1UyNqnydHXiun4UPjK88VDghtoRypR9cLlb7qozkiLRphQxxsCM0KxyxpVPHBVQXdAL3+Yurow==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "d3-voronoi": "1.1.2" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/jake/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/jake/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } }, - "node_modules/jake/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/jake/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@babel/types": "^7.28.2" } }, - "node_modules/jest-canvas-mock": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", - "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, + "license": "MIT", "dependencies": { - "cssfontparser": "^1.2.1", - "moo-color": "^1.0.2" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/jest-circus": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", - "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "node_modules/@types/classnames": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.4.tgz", + "integrity": "sha512-dwmfrMMQb9ujX1uYGvB5ERDlOzBNywnZAZBtOe107/hORWP05ESgU4QyaanZMWYYfd2BzrG78y13/Bju8IQcMQ==", + "deprecated": "This is a stub types definition. classnames provides its own type definitions, so you do not need this installed.", + "license": "MIT", "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "classnames": "*" } }, - "node_modules/jest-circus/node_modules/@jest/console": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/d3-color": "*" } }, - "node_modules/jest-circus/node_modules/@jest/environment": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", - "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", "dependencies": { - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/d3-time": "*" } }, - "node_modules/jest-circus/node_modules/@jest/fake-timers": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", - "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@sinonjs/fake-timers": "^8.0.1", - "@types/node": "*", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/d3-path": "*" } }, - "node_modules/jest-circus/node_modules/@jest/globals": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", - "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "8.56.12", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", + "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", + "license": "MIT", + "peer": true, "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/types": "^27.5.1", - "expect": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/estree": "*", + "@types/json-schema": "*" } }, - "node_modules/jest-circus/node_modules/@jest/source-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", - "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, "dependencies": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9", - "source-map": "^0.6.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/eslint": "*", + "@types/estree": "*" } }, - "node_modules/jest-circus/node_modules/@jest/test-result": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", - "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", "dependencies": { - "@jest/console": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "hoist-non-react-statics": "^3.3.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "peerDependencies": { + "@types/react": "*" } }, - "node_modules/jest-circus/node_modules/@jest/transform": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", - "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.1.0", - "@jest/types": "^27.5.1", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-util": "^27.5.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/jest-circus/node_modules/@sinonjs/fake-timers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", - "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^1.7.0" + "@types/istanbul-lib-report": "*" } }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-circus/node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" - }, - "node_modules/jest-circus/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">=7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, - "node_modules/jest-circus/node_modules/diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" }, - "node_modules/jest-circus/node_modules/expect": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", - "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "node_modules/@types/mapbox-gl": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz", + "integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-circus/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "@types/geojson": "*" } }, - "node_modules/jest-circus/node_modules/jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "undici-types": "~6.21.0" } }, - "node_modules/jest-circus/node_modules/jest-each": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", - "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", - "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" }, - "node_modules/jest-circus/node_modules/jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" }, - "node_modules/jest-circus/node_modules/jest-message-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", - "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^27.5.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true }, - "node_modules/jest-circus/node_modules/jest-mock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", - "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "node_modules/@types/react": { + "version": "17.0.90", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.90.tgz", + "integrity": "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.2.2" } }, - "node_modules/jest-circus/node_modules/jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node_modules/@types/react-dom": { + "version": "17.0.26", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", + "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0" } }, - "node_modules/jest-circus/node_modules/jest-snapshot": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "dependencies": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.2" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" } }, - "node_modules/jest-circus/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "license": "MIT" }, - "node_modules/jest-circus/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" }, - "node_modules/jest-circus/node_modules/throat": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", - "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true }, - "node_modules/jest-circus/node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT" }, - "node_modules/jest-fetch-mock": { + "node_modules/@types/warning": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", - "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", "dependencies": { - "cross-fetch": "^3.0.4", - "promise-polyfill": "^8.1.3" + "@types/yargs-parser": "*" } }, - "node_modules/jest-get-type": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", - "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" }, - "node_modules/jest-haste-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", - "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/graceful-fs": "^4.1.2", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^27.5.1", - "jest-serializer": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "micromatch": "^4.0.4", - "walker": "^1.0.7" + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "engines": { - "node": ">=6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/jest-regex-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", - "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", - "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", - "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" }, "engines": { - "node": ">=10" + "node": "^14.18.0 || >=16.0.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/jest-resolve/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-resolve/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/jest-resolve/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-resolve/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" + "funding": { + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/jest-serializer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", - "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", - "dependencies": { - "@types/node": "*", - "graceful-fs": "^4.2.9" - }, + "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=18" } }, - "node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/coverage-v8/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", "dependencies": { - "color-convert": "^2.0.1" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=8" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@vitest/coverage-v8/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/jest-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/jest-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@vitest/coverage-v8/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", "dependencies": { - "has-flag": "^4.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-validate": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", - "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", "dependencies": { - "@jest/types": "^27.5.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "leven": "^3.1.0", - "pretty-format": "^27.5.1" + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=18" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/jest-validate/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@types/estree": "^1.0.0" } }, - "node_modules/jest-validate/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/jest-validate/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/jest-validate/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, - "engines": { - "node": ">= 10.13.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "tinyspy": "^4.0.3" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "bin": { - "jiti": "bin/jiti.js" + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, - "node_modules/js-sha256": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", - "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==" + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" } }, - "node_modules/jsdom": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", - "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", - "dependencies": { - "abab": "^2.0.5", - "acorn": "^8.2.4", - "acorn-globals": "^6.0.0", - "cssom": "^0.4.4", - "cssstyle": "^2.3.0", - "data-urls": "^2.0.0", - "decimal.js": "^10.2.1", - "domexception": "^2.0.1", - "escodegen": "^2.0.0", - "form-data": "^3.0.0", - "html-encoding-sniffer": "^2.0.1", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "6.0.1", - "saxes": "^5.0.1", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^2.0.0", - "webidl-conversions": "^6.1.0", - "whatwg-encoding": "^1.0.5", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.5.0", - "ws": "^7.4.6", - "xml-name-validator": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", "bin": { - "json5": "lib/cli.js" + "acorn": "bin/acorn" }, "engines": { - "node": ">=6" + "node": ">=0.4.0" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "peerDependencies": { + "acorn": "^8.14.0" } }, - "node_modules/jsonpath": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", - "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", - "dependencies": { - "esprima": "1.2.2", - "static-eval": "2.0.2", - "underscore": "1.12.1" + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/jsonpath/node_modules/esprima": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", - "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">= 14" } }, - "node_modules/jsonpointer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", - "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", - "engines": { - "node": ">=0.10.0" + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/jspdf": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", - "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { - "@babel/runtime": "^7.23.2", - "atob": "^2.1.2", - "btoa": "^1.2.1", - "fflate": "^0.8.1" + "ajv": "^8.0.0" }, - "optionalDependencies": { - "canvg": "^3.0.6", - "core-js": "^3.6.0", - "dompurify": "^2.5.4", - "html2canvas": "^1.0.0-rc.5" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/jss": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", - "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, "dependencies": { - "@babel/runtime": "^7.3.1", - "csstype": "^3.0.2", - "is-in-browser": "^1.1.3", - "tiny-warning": "^1.0.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/jss" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/jss-plugin-camel-case": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", - "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "hyphenate-style-name": "^1.0.3", - "jss": "10.10.0" - } + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true }, - "node_modules/jss-plugin-default-unit": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", - "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.10.0" + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" } }, - "node_modules/jss-plugin-global": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", - "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.10.0" + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/jss-plugin-nested": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", - "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.10.0", - "tiny-warning": "^1.0.2" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jss-plugin-props-sort": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", - "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.10.0" - } + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" }, - "node_modules/jss-plugin-rule-value-function": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", - "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "jss": "10.10.0", - "tiny-warning": "^1.0.2" + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, - "node_modules/jss-plugin-vendor-prefixer": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", - "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", - "dependencies": { - "@babel/runtime": "^7.3.1", - "css-vendor": "^2.0.8", - "jss": "10.10.0" + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { - "node": ">=4.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" } }, - "node_modules/jszip/node_modules/isarray": { + "node_modules/assign-symbols": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/kdbush": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", - "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" - }, - "node_modules/keycloak-js": { - "version": "26.0.5", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.0.5.tgz", - "integrity": "sha512-3biQ5e+yX8EkU3+2ZO+YbWl0emooXt1wyKgZdGCFW8gT0oKeQh/AK6R9a53ACWBzDVoTBI+0GbXP2jF1sFMznw==" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "engines": { - "node": ">=6" + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" } }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "engines": { - "node": ">= 8" + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==" + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", "dependencies": { - "language-subtag-registry": "^0.3.20" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=0.10" - } - }, - "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/leven": { + "node_modules/babel-plugin-macros": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10", + "npm": ">=6" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dependencies": { - "immediate": "~3.0.5" - } + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, "engines": { - "node": ">=10" + "node": ">= 0.6.0" } }, - "node_modules/lines-and-columns": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", "engines": { - "node": ">=6.11.5" + "node": "*" } }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { - "loose-envify": "cli.js" + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "peer": true + }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.0.3" + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "typewise-core": "^1.2" } }, - "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "dependencies": { - "sourcemap-codec": "^1.4.8" + "node": ">=8" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", "dependencies": { - "semver": "^6.0.0" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dependencies": { - "tmpl": "1.0.5" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mapbox-gl": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-2.15.0.tgz", - "integrity": "sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/mapbox-gl-supported": "^2.0.1", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^2.0.6", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^1.3.1", - "@mapbox/whoots-js": "^3.1.0", - "csscolorparser": "~1.0.3", - "earcut": "^2.2.4", - "geojson-vt": "^3.2.1", - "gl-matrix": "^3.4.3", - "grid-index": "^1.1.0", - "kdbush": "^4.0.1", - "murmurhash-js": "^1.0.0", - "pbf": "^3.2.1", - "potpack": "^2.0.0", - "quickselect": "^2.0.0", - "rw": "^1.3.3", - "supercluster": "^8.0.0", - "tinyqueue": "^2.0.3", - "vt-pbf": "^3.1.3" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mdn-data": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", - "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, "dependencies": { - "fs-monkey": "^1.0.4" + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" }, "engines": { - "node": ">= 4.0.0" + "node": ">=10.0.0" } }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/chai/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=8.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 16" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=6.0" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, "engines": { - "node": ">=4" + "node": ">=12.5.0" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.7.7", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.7.tgz", - "integrity": "sha512-+0n11YGyRavUR3IlaOzJ0/4Il1avMvJ1VJfhWfCn24ITQXhRr1gghbhhrda6tgtNcpZaWKdSuwKq20Jb7fnlyw==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { - "schema-utils": "^4.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" + "node": ">=7.0.0" } }, - "node_modules/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" } }, - "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concaveman": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concaveman/-/concaveman-2.0.0.tgz", + "integrity": "sha512-3a9C//4G44/boNehBPZMRh8XxrwBvTXlhENUim+GMm207WoDie/Vq89U5lkhLn3kKA+vxwmwfdQPWIRwjQWoLA==", + "license": "ISC", "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" + "point-in-polygon": "^1.1.0", + "rbush": "^4.0.1", + "robust-predicates": "^3.0.2", + "tinyqueue": "^3.0.0" } }, - "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "node_modules/concaveman/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" }, - "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "node_modules/concaveman/node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, + "quickselect": "^3.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/core-js" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" }, "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, - "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 6" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" + "node-fetch": "^2.7.0" } }, - "node_modules/moo-color": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", - "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { - "color-name": "^1.1.4" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/moo-color/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "license": "MIT", "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" + "tiny-invariant": "^1.0.6" } }, - "node_modules/murmurhash-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", - "integrity": "sha1-sGJ44h/Gw3+lMTcysEEry2rhX1E=" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "utrie": "^1.0.2" } }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "node_modules/css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==" + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true, + "license": "MIT" }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" } }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 6.13.0" + "node": "20 || >=22" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, - "node_modules/node-notifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", - "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", - "optional": true, - "peer": true, - "dependencies": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.2", - "shellwords": "^0.1.1", - "uuid": "^8.3.0", - "which": "^2.0.2" - } + "node_modules/cv-manager": { + "resolved": "", + "link": true }, - "node_modules/node-notifier/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "node_modules/d3-geo": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", + "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", "dependencies": { - "path-key": "^3.0.0" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/nth-check": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", - "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "node_modules/d3-scale/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "boolbase": "~1.0.0" + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" } }, - "node_modules/nwsapi": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", - "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, "engines": { - "node": ">= 6" + "node": ">=12" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "node_modules/d3-time/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" + "internmap": "1 - 2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "node_modules/d3-voronoi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", + "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==", + "license": "BSD-3-Clause" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">= 0.4" + "node": ">=20" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/object.getownpropertydescriptors": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.7.tgz", - "integrity": "sha512-PrJz0C2xJ58FNn11XV2lr4Jt5Gzl94qpy9Lu0JlfEj14z88sqbSBJCBEzdlNUCzY2gburhbrwOZ5BHCmuNUy0g==", + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", "dependencies": { - "array.prototype.reduce": "^1.0.6", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "safe-array-concat": "^1.0.0" + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" }, "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/object.groupby": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", - "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1" - } + "node_modules/date-arithmetic": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", + "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==", + "license": "MIT" }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ee-first": "1.1.1" + "ms": "^2.1.3" }, "engines": { - "node": ">= 0.8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "engines": { - "node": ">= 0.8" - } + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "type-detect": "^4.0.0" }, "engines": { "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -15351,1659 +6926,1724 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dependencies": { - "p-limit": "^3.0.2" - }, + "node_modules/density-clustering": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/density-clustering/-/density-clustering-1.3.0.tgz", + "integrity": "sha512-icpmBubVTwLnsaor9qH/4tG5+7+f61VcqMN3V3pm9sxxSCt2Jcs0zWOgwZW9ARJYaKD3FumIgHiMOcIMRRAzFQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "engines": { - "node": ">=6" - } + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } + "node_modules/electron-to-chromium": { + "version": "1.5.279", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", + "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", + "license": "ISC" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 4" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "peer": true, "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10.13.0" } }, - "node_modules/parse5": { + "node_modules/entities": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.8" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "node_modules/env-cmd": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", + "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==", + "license": "MIT", "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" + "commander": "^4.0.0", + "cross-spawn": "^7.0.0" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=8.0.0" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "engines": { - "node": ">=8" + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" } }, - "node_modules/path-is-absolute": { + "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "engines": { - "node": "14 || >=16.14" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT", + "peer": true }, - "node_modules/pbf": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", - "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==", + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { - "ieee754": "^1.1.12", - "resolve-protobuf-schema": "^2.1.0" + "es-errors": "^1.3.0" }, - "bin": { - "pbf": "bin/pbf" + "engines": { + "node": ">= 0.4" } }, - "node_modules/perfect-scrollbar": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.5.tgz", - "integrity": "sha512-dzalfutyP3e/FOpdlhVryN4AJ5XDVauVWxybSkLZmakFE2sS3y3pc4JnSprw8tGmHvkaG5Edr5T7LBTZ+WWU2g==" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=8.6" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dependencies": { - "find-up": "^4.0.0" + "node": ">=10" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dependencies": { - "p-locate": "^4.1.0" + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "p-try": "^2.0.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dependencies": { - "p-limit": "^2.2.0" - }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dependencies": { - "find-up": "^3.0.0" - }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "locate-path": "^3.0.0" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "p-try": "^2.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10" } }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", "dependencies": { - "p-limit": "^2.0.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=6" + "node": ">=4.0" } }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", "engines": { - "node": ">=4" + "node": ">=4.0" } }, - "node_modules/point-in-polygon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", - "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==" - }, - "node_modules/polygon-clipping": { - "version": "0.15.7", - "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", - "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", - "dependencies": { - "robust-predicates": "^3.0.2", - "splaytree": "^3.1.0" + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/polygon-clipping/node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">=0.8.x" } }, - "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-attribute-case-insensitive": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", - "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/postcss-browser-comments": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", - "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" - }, - "peerDependencies": { - "browserslist": ">=4", - "postcss": ">=8" + "node": ">=12.0.0" } }, - "node_modules/postcss-calc": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", - "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0" + "is-extendable": "^0.1.0" }, - "peerDependencies": { - "postcss": "^8.2.2" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/postcss-clamp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", - "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", "engines": { - "node": ">=7.6.0" - }, - "peerDependencies": { - "postcss": "^8.4.6" + "node": ">=6.0.0" } }, - "node_modules/postcss-color-functional-notation": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", - "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" } }, - "node_modules/postcss-color-hex-alpha": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", - "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" + "node": ">=16.0.0" } }, - "node_modules/postcss-color-rebeccapurple": { + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, + "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", - "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "to-regex-range": "^5.0.1" }, "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "node": ">=8" } }, - "node_modules/postcss-colormin": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", - "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", "dependencies": { - "browserslist": "^4.21.4", - "caniuse-api": "^3.0.0", - "colord": "^2.9.1", - "postcss-value-parser": "^4.2.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">=10" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-convert-values": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", - "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", "dependencies": { - "browserslist": "^4.21.4", - "postcss-value-parser": "^4.2.0" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=16" } }, - "node_modules/postcss-custom-media": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", - "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "is-callable": "^1.2.7" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.3" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-custom-properties": { - "version": "12.1.11", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.11.tgz", - "integrity": "sha512-0IDJYhgU8xDv1KY6+VgUwuQkVtmYzRwu+dMjnmdMafXYv86SWqfxkc7qdDvWS38vsjaEtv8e0vGOUQrAiMBLpQ==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", "dependencies": { - "postcss-value-parser": "^4.2.0" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/postcss-custom-selectors": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", - "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", - "dependencies": { - "postcss-selector-parser": "^6.0.4" - }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=14" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.3" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/postcss-dir-pseudo-class": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", - "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "node_modules/formik": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", + "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "license": "Apache-2.0", "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" }, "peerDependencies": { - "postcss": "^8.2" + "react": ">=16.8.0" } }, - "node_modules/postcss-discard-comments": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", - "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "node_modules/formik/node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=0.10.0" } }, - "node_modules/postcss-discard-duplicates": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", - "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } + "node_modules/formik/node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", + "license": "MIT" }, - "node_modules/postcss-discard-empty": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", - "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/postcss-discard-overridden": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", - "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-double-position-gradients": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", - "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-env-function": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", - "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" + "node": ">=6.9.0" } }, - "node_modules/postcss-flexbugs-fixes": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", - "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", - "peerDependencies": { - "postcss": "^8.1.4" + "node_modules/geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "license": "MIT", + "engines": { + "node": ">= 0.10" } }, - "node_modules/postcss-focus-visible": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", - "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "node_modules/geojson-equality": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", + "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" + "deep-equal": "^1.0.0" } }, - "node_modules/postcss-focus-within": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", - "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "node_modules/geojson-rbush": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz", + "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.9" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "peerDependencies": { - "postcss": "^8.4" + "@turf/bbox": "*", + "@turf/helpers": "6.x", + "@turf/meta": "6.x", + "@types/geojson": "7946.0.8", + "rbush": "^3.0.1" } }, - "node_modules/postcss-font-variant": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", - "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "peerDependencies": { - "postcss": "^8.1.0" + "node_modules/geojson-rbush/node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" } }, - "node_modules/postcss-gap-properties": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", - "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" - } + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" }, - "node_modules/postcss-image-set-function": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", - "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" }, - "peerDependencies": { - "postcss": "^8.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-initial": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", - "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "peerDependencies": { - "postcss": "^8.0.0" + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", "dependencies": { - "camelcase-css": "^2.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" + "node": ">=10.13.0" } }, - "node_modules/postcss-lab-function": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", - "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^1.1.0", - "postcss-value-parser": "^4.2.0" - }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", "peerDependencies": { - "postcss": "^8.2" + "csstype": "^3.0.10" } }, - "node_modules/postcss-loader": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", - "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", - "dependencies": { - "cosmiconfig": "^7.0.0", - "klona": "^2.0.5", - "semver": "^7.3.5" - }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { - "node": ">= 12.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-logical": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", - "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-media-minmax": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", - "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">=8" } }, - "node_modules/postcss-merge-longhand": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", - "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^5.1.1" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" + "es-define-property": "^1.0.0" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-merge-rules": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", - "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", - "dependencies": { - "browserslist": "^4.21.4", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^3.1.0", - "postcss-selector-parser": "^6.0.5" - }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-minify-font-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", - "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-minify-gradients": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", - "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { - "colord": "^2.9.1", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" + "function-bind": "^1.1.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">= 0.4" } }, - "node_modules/postcss-minify-params": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", - "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", "dependencies": { - "browserslist": "^4.21.4", - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "react-is": "^16.7.0" } }, - "node_modules/postcss-minify-selectors": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", - "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.5" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz", - "integrity": "sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==", + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^6.0.2", - "postcss-value-parser": "^4.1.0" + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" }, "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">=8.0.0" } }, - "node_modules/postcss-modules-scope": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.1.0.tgz", - "integrity": "sha512-SaIbK8XW+MZbd0xHPf7kdfA/3eOt7vxJ72IRecn3EzuZVLr1r0orzf0MX/pN8m+NMDoo6X/SQd8oeKqGZd8PXg==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 14" } }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", "dependencies": { - "icss-utils": "^5.0.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 14" } }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": ">= 4" } }, - "node_modules/postcss-nesting": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", - "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", - "dependencies": { - "@csstools/selector-specificity": "^2.0.0", - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", "funding": { "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://opencollective.com/immer" } }, - "node_modules/postcss-normalize": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", - "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", "dependencies": { - "@csstools/normalize.css": "*", - "postcss-browser-comments": "^4", - "sanitize.css": "*" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">= 12" + "node": ">=6" }, - "peerDependencies": { - "browserslist": ">= 4", - "postcss": ">= 8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-normalize-charset": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", - "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=0.8.19" } }, - "node_modules/postcss-normalize-display-values": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", - "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=8" } }, - "node_modules/postcss-normalize-positions": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", - "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">= 0.4" } }, - "node_modules/postcss-normalize-repeat-style": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", - "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=12" } }, - "node_modules/postcss-normalize-string": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", - "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "loose-envify": "^1.0.0" } }, - "node_modules/postcss-normalize-timing-functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", - "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-normalize-unicode": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", - "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", "dependencies": { - "browserslist": "^4.21.4", - "postcss-value-parser": "^4.2.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-normalize-url": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", - "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", "dependencies": { - "normalize-url": "^6.0.1", - "postcss-value-parser": "^4.2.0" + "has-bigints": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-normalize-whitespace": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", - "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-opacity-percentage": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.3.tgz", - "integrity": "sha512-An6Ba4pHBiDtyVpSLymUUERMo2cU7s+Obz6BTrS+gxkbnSBNKSuD0AVUc+CpBMrpVPKKfoVz0WQCX+Tnst0i4A==", - "funding": [ - { - "type": "kofi", - "url": "https://ko-fi.com/mrcgrtz" - }, - { - "type": "liberapay", - "url": "https://liberapay.com/mrcgrtz" - } - ], + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-ordered-values": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", - "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { - "cssnano-utils": "^3.1.0", - "postcss-value-parser": "^4.2.0" + "hasown": "^2.0.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-overflow-shorthand": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", - "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-page-break": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", - "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "peerDependencies": { - "postcss": "^8" + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/postcss-place": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", - "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" - } - }, - "node_modules/postcss-preset-env": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.3.tgz", - "integrity": "sha512-T1LgRm5uEVFSEF83vHZJV2z19lHg4yJuZ6gXZZkqVsqv63nlr6zabMH3l4Pc01FQCyfWVrh2GaUeCVy9Po+Aag==", - "dependencies": { - "@csstools/postcss-cascade-layers": "^1.1.1", - "@csstools/postcss-color-function": "^1.1.1", - "@csstools/postcss-font-format-keywords": "^1.0.1", - "@csstools/postcss-hwb-function": "^1.0.2", - "@csstools/postcss-ic-unit": "^1.0.1", - "@csstools/postcss-is-pseudo-class": "^2.0.7", - "@csstools/postcss-nested-calc": "^1.0.0", - "@csstools/postcss-normalize-display-values": "^1.0.1", - "@csstools/postcss-oklab-function": "^1.1.1", - "@csstools/postcss-progressive-custom-properties": "^1.3.0", - "@csstools/postcss-stepped-value-functions": "^1.0.1", - "@csstools/postcss-text-decoration-shorthand": "^1.0.0", - "@csstools/postcss-trigonometric-functions": "^1.0.2", - "@csstools/postcss-unset-value": "^1.0.2", - "autoprefixer": "^10.4.13", - "browserslist": "^4.21.4", - "css-blank-pseudo": "^3.0.3", - "css-has-pseudo": "^3.0.4", - "css-prefers-color-scheme": "^6.0.3", - "cssdb": "^7.1.0", - "postcss-attribute-case-insensitive": "^5.0.2", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^4.2.4", - "postcss-color-hex-alpha": "^8.0.4", - "postcss-color-rebeccapurple": "^7.1.1", - "postcss-custom-media": "^8.0.2", - "postcss-custom-properties": "^12.1.10", - "postcss-custom-selectors": "^6.0.3", - "postcss-dir-pseudo-class": "^6.0.5", - "postcss-double-position-gradients": "^3.1.2", - "postcss-env-function": "^4.0.6", - "postcss-focus-visible": "^6.0.4", - "postcss-focus-within": "^5.0.4", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^3.0.5", - "postcss-image-set-function": "^4.0.7", - "postcss-initial": "^4.0.1", - "postcss-lab-function": "^4.2.1", - "postcss-logical": "^5.0.4", - "postcss-media-minmax": "^5.0.0", - "postcss-nesting": "^10.2.0", - "postcss-opacity-percentage": "^1.1.2", - "postcss-overflow-shorthand": "^3.0.4", - "postcss-page-break": "^3.0.4", - "postcss-place": "^7.0.5", - "postcss-pseudo-class-any-link": "^7.1.6", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^6.0.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "node": ">=0.10.0" } }, - "node_modules/postcss-pseudo-class-any-link": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", - "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12 || ^14 || >=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "node": ">=8" } }, - "node_modules/postcss-reduce-initial": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", - "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", "dependencies": { - "browserslist": "^4.21.4", - "caniuse-api": "^3.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" + "node": ">=0.10.0" } }, - "node_modules/postcss-reduce-transforms": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", - "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, + "node_modules/is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", + "license": "MIT" + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-replace-overflow-wrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", - "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "peerDependencies": { - "postcss": "^8.0.3" + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" } }, - "node_modules/postcss-selector-not": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", - "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": "^12 || ^14 || >=16" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "isobject": "^3.0.1" }, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/postcss-svgo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", - "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^2.7.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", "engines": { - "node": ">= 10" - } - }, - "node_modules/postcss-svgo/node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", "dependencies": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=8.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">= 6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/postcss-svgo/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "node_modules/postcss-svgo/node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss-svgo/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/postcss-svgo/node_modules/svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", - "css-select": "^4.1.3", - "css-tree": "^1.1.3", - "csso": "^4.2.0", - "picocolors": "^1.0.0", - "stable": "^0.1.8" - }, - "bin": { - "svgo": "bin/svgo" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=10" } }, - "node_modules/postcss-unique-selectors": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", - "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.5" + "semver": "^7.5.3" }, "engines": { - "node": "^10 || ^12 || >=14.0" + "node": ">=10" }, - "peerDependencies": { - "postcss": "^8.2.15" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "node_modules/potpack": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", - "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, "engines": { - "node": ">=6" + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "node_modules/jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "license": "MIT", "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/jest-diff/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -17011,1907 +8651,1723 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/promise": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", - "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", - "dependencies": { - "asap": "~2.0.6" - } - }, - "node_modules/promise-polyfill": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", - "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types-extra": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", - "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "dependencies": { - "react-is": "^16.3.2", - "warning": "^4.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" - } - }, - "node_modules/property-expr": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", - "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" - }, - "node_modules/protocol-buffers-schema": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz", - "integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw==" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "engines": { - "node": ">=6" + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" } }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/quickselect": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", - "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" - }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", "dependencies": { - "performance-now": "^2.1.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/raf-schd": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", - "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", "dependencies": { - "safe-buffer": "^5.1.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">= 0.8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/rbush": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", - "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", - "dependencies": { - "quickselect": "^1.0.1" + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/rbush/node_modules/quickselect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", - "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/react-app-polyfill": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", - "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, "dependencies": { - "core-js": "^3.19.2", - "object-assign": "^4.1.1", - "promise": "^8.1.0", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.9", - "whatwg-fetch": "^3.6.2" + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">=14" + "node": ">= 10.13.0" } }, - "node_modules/react-bootstrap": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.7.0.tgz", - "integrity": "sha512-Jcrn6aUuRVBeSB6dzKODKZU1TONOdhAxu0IDm4Sv74SJUm98dMdhSotF2SNvFEADANoR+stV+7TK6SNX1wWu5w==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, "dependencies": { - "@babel/runtime": "^7.17.2", - "@restart/hooks": "^0.4.6", - "@restart/ui": "^1.4.1", - "@types/react-transition-group": "^4.4.4", - "classnames": "^2.3.1", - "dom-helpers": "^5.2.1", - "invariant": "^2.2.4", - "prop-types": "^15.8.1", - "prop-types-extra": "^1.1.0", - "react-transition-group": "^4.4.2", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" + "has-flag": "^4.0.0" }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/react-confirm-alert": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/react-confirm-alert/-/react-confirm-alert-2.8.0.tgz", - "integrity": "sha512-qvNjJWuWUpTh+q4NecUjCMIWLNDl8IwW6JRIky5pzoiFBXsLWSA2Z1VsaDsQedwgyxEpKnMEJFETkDogBpv/kA==", - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, - "node_modules/react-dev-utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", - "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.16.0", - "address": "^1.1.2", - "browserslist": "^4.18.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "detect-port-alt": "^1.1.6", - "escape-string-regexp": "^4.0.0", - "filesize": "^8.0.6", - "find-up": "^5.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.0", - "global-modules": "^2.0.0", - "globby": "^11.0.4", - "gzip-size": "^6.0.0", - "immer": "^9.0.7", - "is-root": "^2.1.0", - "loader-utils": "^3.2.0", - "open": "^8.4.0", - "pkg-up": "^3.1.0", - "prompts": "^2.4.2", - "react-error-overlay": "^6.0.11", - "recursive-readdir": "^2.2.2", - "shell-quote": "^1.7.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "argparse": "^2.0.1" }, - "engines": { - "node": ">=14" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/react-dev-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/react-dev-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=20" } }, - "node_modules/react-dev-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=20" } }, - "node_modules/react-dev-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/react-dev-utils/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } }, - "node_modules/react-dev-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" }, - "node_modules/react-dev-utils/node_modules/loader-utils": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", - "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", - "engines": { - "node": ">= 12.13.0" - } + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" }, - "node_modules/react-dev-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" + "node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "node_modules/jspdf": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", + "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" }, - "peerDependencies": { - "react": "17.0.2" + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/react-double-scrollbar": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz", - "integrity": "sha512-dLz3/WBIpgFnzFY0Kb4aIYBMT2BWomHuW2DH6/9jXfS6/zxRRBUFQ04My4HIB7Ma7QoRBpcy8NtkPeFgcGBpgg==", + "node_modules/jss": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", - "engines": { - "node": ">=0.12.0" - }, - "peerDependencies": { - "react": ">= 0.14.7" - } - }, - "node_modules/react-error-overlay": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", - "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" - }, - "node_modules/react-fast-compare": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz", - "integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==" - }, - "node_modules/react-hook-form": { - "version": "7.41.5", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.41.5.tgz", - "integrity": "sha512-DAKjSJ7X9f16oQrP3TW2/eD9N6HOgrmIahP4LOdFphEWVfGZ2LulFd6f6AQ/YS/0cx/5oc4j8a1PXxuaurWp/Q==", - "engines": { - "node": ">=12.22.0" + "dependencies": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18" + "url": "https://opencollective.com/jss" } }, - "node_modules/react-hot-toast": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", - "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "node_modules/jss-plugin-camel-case": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", + "license": "MIT", "dependencies": { - "goober": "^2.1.10" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.10.0" } }, - "node_modules/react-hot-toast/node_modules/goober": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", - "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", - "peerDependencies": { - "csstype": "^3.0.10" + "node_modules/jss-plugin-default-unit": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "node_modules/jss-plugin-global": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + "node_modules/jss-plugin-nested": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } }, - "node_modules/react-map-gl": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.0.23.tgz", - "integrity": "sha512-874jEtdS/fB2R4jSJKud9va0H0GlxhtiSFuUMATiniQ7A2lQnZLkZIPEWwIPkMmNZDXNlTAkxWEdSHzsqADVAw==", + "node_modules/jss-plugin-props-sort": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", + "license": "MIT", "dependencies": { - "@types/mapbox-gl": "^2.6.0" - }, - "peerDependencies": { - "mapbox-gl": "*", - "react": ">=16.3.0" + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" } }, - "node_modules/react-perfect-scrollbar": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/react-perfect-scrollbar/-/react-perfect-scrollbar-1.5.8.tgz", - "integrity": "sha512-bQ46m70gp/HJtiBOF3gRzBISSZn8FFGNxznTdmTG8AAwpxG1bJCyn7shrgjEvGSQ5FJEafVEiosY+ccER11OSA==", + "node_modules/jss-plugin-rule-value-function": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", + "license": "MIT", "dependencies": { - "perfect-scrollbar": "^1.5.0", - "prop-types": "^15.6.1" - }, - "peerDependencies": { - "react": ">=16.3.3", - "react-dom": ">=16.3.3" + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" } }, - "node_modules/react-popper": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", - "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "node_modules/jss-plugin-vendor-prefixer": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", + "license": "MIT", "dependencies": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.10.0" } }, - "node_modules/react-redux": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", - "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { - "@babel/runtime": "^7.12.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/use-sync-external-store": "^0.0.3", - "hoist-non-react-statics": "^3.3.2", - "react-is": "^18.0.0", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "@types/react": "^16.8 || ^17.0 || ^18.0", - "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0", - "react-native": ">=0.59", - "redux": "^4" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "redux": { - "optional": true - } + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" } }, - "node_modules/react-redux/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" }, - "node_modules/react-refresh": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", - "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/react-router": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.2.tgz", - "integrity": "sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ==", + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keycloak-js": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.0.5.tgz", + "integrity": "sha512-3biQ5e+yX8EkU3+2ZO+YbWl0emooXt1wyKgZdGCFW8gT0oKeQh/AK6R9a53ACWBzDVoTBI+0GbXP2jF1sFMznw==", + "license": "Apache-2.0" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", "dependencies": { - "history": "^5.2.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, - "peerDependencies": { - "react": ">=16.8" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/react-router-dom": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.2.tgz", - "integrity": "sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", "dependencies": { - "history": "^5.2.0", - "react-router": "6.2.2" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "immediate": "~3.0.5" } }, - "node_modules/react-scripts": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.0.tgz", - "integrity": "sha512-3i0L2CyIlROz7mxETEdfif6Sfhh9Lfpzi10CtcGs1emDQStmZfWjJbAIMtRD0opVUjQuFWqHZyRZ9PPzKCFxWg==", - "dependencies": { - "@babel/core": "^7.16.0", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", - "@svgr/webpack": "^5.5.0", - "babel-jest": "^27.4.2", - "babel-loader": "^8.2.3", - "babel-plugin-named-asset-import": "^0.3.8", - "babel-preset-react-app": "^10.0.1", - "bfj": "^7.0.2", - "browserslist": "^4.18.1", - "camelcase": "^6.2.1", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "css-loader": "^6.5.1", - "css-minimizer-webpack-plugin": "^3.2.0", - "dotenv": "^10.0.0", - "dotenv-expand": "^5.1.0", - "eslint": "^8.3.0", - "eslint-config-react-app": "^7.0.0", - "eslint-webpack-plugin": "^3.1.1", - "file-loader": "^6.2.0", - "fs-extra": "^10.0.0", - "html-webpack-plugin": "^5.5.0", - "identity-obj-proxy": "^3.0.0", - "jest": "^27.4.3", - "jest-resolve": "^27.4.2", - "jest-watch-typeahead": "^1.0.0", - "mini-css-extract-plugin": "^2.4.5", - "postcss": "^8.4.4", - "postcss-flexbugs-fixes": "^5.0.2", - "postcss-loader": "^6.2.1", - "postcss-normalize": "^10.0.1", - "postcss-preset-env": "^7.0.1", - "prompts": "^2.4.2", - "react-app-polyfill": "^3.0.0", - "react-dev-utils": "^12.0.0", - "react-refresh": "^0.11.0", - "resolve": "^1.20.0", - "resolve-url-loader": "^4.0.0", - "sass-loader": "^12.3.0", - "semver": "^7.3.5", - "source-map-loader": "^3.0.0", - "style-loader": "^3.3.1", - "tailwindcss": "^3.0.2", - "terser-webpack-plugin": "^5.2.5", - "webpack": "^5.64.4", - "webpack-dev-server": "^4.6.0", - "webpack-manifest-plugin": "^4.0.2", - "workbox-webpack-plugin": "^6.4.1" - }, - "bin": { - "react-scripts": "bin/react-scripts.js" - }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "license": "MIT", + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - }, - "peerDependencies": { - "react": ">= 16", - "typescript": "^3.2.1 || ^4" + "node": ">=6.11.5" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/react-scripts/node_modules/@jest/console": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0" + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8.9.0" } }, - "node_modules/react-scripts/node_modules/@jest/core": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", - "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/reporters": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^27.5.1", - "jest-config": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-resolve-dependencies": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "jest-watcher": "^27.5.1", - "micromatch": "^4.0.4", - "rimraf": "^3.0.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-scripts/node_modules/@jest/environment": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", - "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", - "dependencies": { - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/@jest/fake-timers": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", - "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@sinonjs/fake-timers": "^8.0.1", - "@types/node": "*", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" + "js-tokens": "^3.0.0 || ^4.0.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/react-scripts/node_modules/@jest/globals": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", - "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/types": "^27.5.1", - "expect": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "yallist": "^3.0.2" } }, - "node_modules/react-scripts/node_modules/@jest/reporters": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", - "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.2", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-haste-map": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "slash": "^3.0.0", - "source-map": "^0.6.0", - "string-length": "^4.0.1", - "terminal-link": "^2.0.0", - "v8-to-istanbul": "^8.1.0" - }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=12" } }, - "node_modules/react-scripts/node_modules/@jest/schemas": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", - "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" } }, - "node_modules/react-scripts/node_modules/@jest/source-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", - "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", "dependencies": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9", - "source-map": "^0.6.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" } }, - "node_modules/react-scripts/node_modules/@jest/test-result": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", - "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "node_modules/mapbox-gl": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-2.15.0.tgz", + "integrity": "sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A==", + "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@jest/console": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^2.0.1", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.4", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.4.3", + "grid-index": "^1.1.0", + "kdbush": "^4.0.1", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^2.0.0", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^8.0.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.3" } }, - "node_modules/react-scripts/node_modules/@jest/test-sequencer": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", - "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", - "dependencies": { - "@jest/test-result": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-runtime": "^27.5.1" - }, + "node_modules/mapbox-gl/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 0.4" } }, - "node_modules/react-scripts/node_modules/@jest/transform": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", - "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.1.0", - "@jest/types": "^27.5.1", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-util": "^27.5.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8.6" } }, - "node_modules/react-scripts/node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" - }, - "node_modules/react-scripts/node_modules/@sinonjs/fake-timers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", - "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", - "dependencies": { - "@sinonjs/commons": "^1.7.0" + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/react-scripts/node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", - "dependencies": { - "@types/yargs-parser": "*" + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" } }, - "node_modules/react-scripts/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, "dependencies": { - "color-convert": "^2.0.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/react-scripts/node_modules/babel-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", - "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", - "dependencies": { - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^27.5.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" + "node": ">=4" } }, - "node_modules/react-scripts/node_modules/babel-plugin-jest-hoist": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", - "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.0.0", - "@types/babel__traverse": "^7.0.6" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "*" } }, - "node_modules/react-scripts/node_modules/babel-preset-jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", - "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", - "dependencies": { - "babel-plugin-jest-hoist": "^27.5.1", - "babel-preset-current-node-syntax": "^1.0.0" - }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/react-scripts/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "color-name": "^1.1.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/react-scripts/node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" }, - "node_modules/react-scripts/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true }, - "node_modules/react-scripts/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">=7.0.0" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/react-scripts/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=0.10.0" } }, - "node_modules/react-scripts/node_modules/emittery": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", - "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-scripts/node_modules/expect": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", - "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-scripts/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/react-scripts/node_modules/html-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==", + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/react-scripts/node_modules/jest": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", - "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", - "dependencies": { - "@jest/core": "^27.5.1", - "import-local": "^3.0.2", - "jest-cli": "^27.5.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-scripts/node_modules/jest-changed-files": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", - "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "execa": "^5.0.0", - "throat": "^6.0.1" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 0.8.0" } }, - "node_modules/react-scripts/node_modules/jest-cli": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", - "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/core": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "prompts": "^2.0.1", - "yargs": "^16.2.0" - }, - "bin": { - "jest": "bin/jest.js" + "yocto-queue": "^0.1.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-scripts/node_modules/jest-config": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", - "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.8.0", - "@jest/test-sequencer": "^27.5.1", - "@jest/types": "^27.5.1", - "babel-jest": "^27.5.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.1", - "graceful-fs": "^4.2.9", - "jest-circus": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-jasmine2": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runner": "^27.5.1", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "p-limit": "^3.0.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "peerDependencies": { - "ts-node": ">=9.0.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "ts-node": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-scripts/node_modules/jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" + "callsites": "^3.0.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=6" } }, - "node_modules/react-scripts/node_modules/jest-docblock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", - "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-scripts/node_modules/jest-each": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", - "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1" + "entities": "^6.0.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/react-scripts/node_modules/jest-environment-jsdom": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", - "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1", - "jsdom": "^16.6.0" - }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" } }, - "node_modules/react-scripts/node_modules/jest-environment-node": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", - "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" - }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" } }, - "node_modules/react-scripts/node_modules/jest-jasmine2": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", - "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "throat": "^6.0.1" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-scripts/node_modules/jest-leak-detector": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", - "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", - "dependencies": { - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" } }, - "node_modules/react-scripts/node_modules/jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 14.16" } }, - "node_modules/react-scripts/node_modules/jest-message-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", - "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^27.5.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "bin": { + "pbf": "bin/pbf" } }, - "node_modules/react-scripts/node_modules/jest-mock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", - "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*" - }, + "node_modules/perfect-scrollbar": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz", + "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/react-scripts/node_modules/jest-resolve-dependencies": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", - "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polygon-clipping": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", + "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", + "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-snapshot": "^27.5.1" - }, + "robust-predicates": "^3.0.2", + "splaytree": "^3.1.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 0.4" } }, - "node_modules/react-scripts/node_modules/jest-runner": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", - "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/environment": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.8.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", - "jest-environment-node": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-leak-detector": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "source-map-support": "^0.5.6", - "throat": "^6.0.1" + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^10 || ^12 || >=14" } }, - "node_modules/react-scripts/node_modules/jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 0.8.0" } }, - "node_modules/react-scripts/node_modules/jest-snapshot": { + "node_modules/pretty-format": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "dependencies": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.2" + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", - "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", - "dependencies": { - "ansi-escapes": "^4.3.1", - "chalk": "^4.0.0", - "jest-regex-util": "^28.0.0", - "jest-watcher": "^28.0.0", - "slash": "^4.0.0", - "string-length": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=10" }, - "peerDependencies": { - "jest": "^27.0.0 || ^28.0.0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/console": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", - "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", - "dependencies": { - "@jest/types": "^28.1.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^28.1.3", - "jest-util": "^28.1.3", - "slash": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "engines": { - "node": ">=8" - } + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", - "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", "dependencies": { - "@jest/console": "^28.1.3", - "@jest/types": "^28.1.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/types": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", - "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", "dependencies": { - "@jest/schemas": "^28.1.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "react-is": "^16.3.2", + "warning": "^4.0.0" }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "peerDependencies": { + "react": ">=0.14.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "node_modules/prop-types-extra/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/emittery": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", - "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-message-util": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", - "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^28.1.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^28.1.3", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "engines": { - "node": ">=8" - } + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", - "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "node": ">=6" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-util": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", - "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", - "dependencies": { - "@jest/types": "^28.1.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", - "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, "dependencies": { - "@jest/test-result": "^28.1.3", - "@jest/types": "^28.1.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.10.2", - "jest-util": "^28.1.3", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + "performance-now": "^2.1.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "peer": true, "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" + "safe-buffer": "^5.1.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "quickselect": "^1.0.1" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/pretty-format": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", - "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "node_modules/rbush/node_modules/quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", + "license": "ISC" + }, + "node_modules/react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "license": "MIT", "dependencies": { - "@jest/schemas": "^28.1.3", - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/string-length": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", - "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", "dependencies": { - "char-regex": "^2.0.0", - "strip-ansi": "^7.0.1" + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" }, - "engines": { - "node": ">=12.20" + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", - "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", - "engines": { - "node": ">=12.20" + "node_modules/react-confirm-alert": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/react-confirm-alert/-/react-confirm-alert-2.8.0.tgz", + "integrity": "sha512-qvNjJWuWUpTh+q4NecUjCMIWLNDl8IwW6JRIky5pzoiFBXsLWSA2Z1VsaDsQedwgyxEpKnMEJFETkDogBpv/kA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" }, + "peerDependencies": { + "react": "17.0.2" + } + }, + "node_modules/react-double-scrollbar": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz", + "integrity": "sha512-dLz3/WBIpgFnzFY0Kb4aIYBMT2BWomHuW2DH6/9jXfS6/zxRRBUFQ04My4HIB7Ma7QoRBpcy8NtkPeFgcGBpgg==", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.12.0" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "peerDependencies": { + "react": ">= 0.14.7" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" } }, - "node_modules/react-scripts/node_modules/jest-watcher": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", - "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", "dependencies": { - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "jest-util": "^27.5.1", - "string-length": "^4.0.1" + "csstype": "^3.1.3", + "goober": "^2.1.16" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" } }, - "node_modules/react-scripts/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/react-map-gl": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.1.9.tgz", + "integrity": "sha512-KsCc8Gyn05wVGlHZoopaiiCr0RCAQ6LDISo5sEy1/pV/d7RlozkF946tiX7IgyijJQMRujHol5QdwUPESjh73w==", + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@maplibre/maplibre-gl-style-spec": "^19.2.1", + "@types/mapbox-gl": ">=1.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0 <5.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } } }, - "node_modules/react-scripts/node_modules/throat": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", - "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" - }, - "node_modules/react-scripts/node_modules/v8-to-istanbul": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", - "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "node_modules/react-perfect-scrollbar": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/react-perfect-scrollbar/-/react-perfect-scrollbar-1.5.8.tgz", + "integrity": "sha512-bQ46m70gp/HJtiBOF3gRzBISSZn8FFGNxznTdmTG8AAwpxG1bJCyn7shrgjEvGSQ5FJEafVEiosY+ccER11OSA==", + "license": "MIT", "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0", - "source-map": "^0.7.3" + "perfect-scrollbar": "^1.5.0", + "prop-types": "^15.6.1" }, - "engines": { - "node": ">=10.12.0" + "peerDependencies": { + "react": ">=16.3.3", + "react-dom": ">=16.3.3" } }, - "node_modules/react-scripts/node_modules/v8-to-istanbul/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" } }, - "node_modules/react-scripts/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/react-redux": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", + "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } } }, - "node_modules/react-scripts/node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } + "node_modules/react-redux/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, - "node_modules/react-scripts/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "node_modules/react-scripts/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "@remix-run/router": "1.23.2" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" } }, - "node_modules/react-scripts/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, "engines": { - "node": ">=10" + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/react-secure-storage": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/react-secure-storage/-/react-secure-storage-1.3.2.tgz", "integrity": "sha512-pNCyksbLXWIYRS9vCzERXdIMErtx9Ik70TPtLKivcq44+zYybbxA72wpp5ivghK9Xe0gRku2w/7zBy/9n+RtKA==", + "license": "MIT", "dependencies": { "crypto-js": "^4.1.1", "murmurhash-js": "^1.0.0" } }, "node_modules/react-smooth": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", - "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/react-spinners": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.11.0.tgz", "integrity": "sha512-rDZc0ABWn/M1OryboGsWVmIPg8uYWl0L35jPUhr40+Yg+syVPjeHwvnB7XWaRpaKus3M0cG9BiJA+ZB0dAwWyw==", + "license": "MIT", "dependencies": { "@emotion/react": "^11.1.4" }, @@ -18921,9 +10377,10 @@ } }, "node_modules/react-to-print": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.0.1.tgz", - "integrity": "sha512-WcGiim2VWiHW7TR0PB9Xqv/SOOtnDJbjhlSOeMB1N+lxyFLuoQ8GIoNAw/ci7+sCkbXreEKmiy1ZWgd87v7U2Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.2.0.tgz", + "integrity": "sha512-IX2D0mebKMgYTBD6s5tf9B7YRL3RFWjRoevYK8JKgRwn94Rep7PFZyeOTGjCmXofKB1SKzvPSzDrAMG4I2PIwg==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" } @@ -18932,6 +10389,7 @@ "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -18947,6 +10405,7 @@ "version": "5.8.4", "resolved": "https://registry.npmjs.org/react-widgets/-/react-widgets-5.8.4.tgz", "integrity": "sha512-WcA/K+eVKAW+vyeQKdRqo2gmnLqHbNSDDKQ84j/wyhbautCRrGbjWAmKb4+tI3OzUgCAAEJDZ75azAY2WoKWYQ==", + "license": "MIT", "dependencies": { "@restart/hooks": "^0.4.5", "@types/classnames": "^2.3.1", @@ -18965,9 +10424,10 @@ } }, "node_modules/reactstrap": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-9.2.2.tgz", - "integrity": "sha512-4KroiGOdqZLAnMGzHjpErW3G7bLB+QbKzzMLIDXydPIV0y74lpdL7WtXHkLWAGInd97WCPNx4+R0NQDPyzIfhw==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-9.2.3.tgz", + "integrity": "sha512-1nXy7FIBIoOgXr3AIHOpgzcZXdj6rZE5YvNSPd1hYgwv8X64m6TAJsU0ExlieJdlRXhaRfTYRSZoTWa127b0gw==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@popperjs/core": "^2.6.0", @@ -18981,48 +10441,32 @@ "react-dom": ">=16.8.0" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, "node_modules/recharts": { - "version": "2.12.7", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.7.tgz", - "integrity": "sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", - "react-is": "^16.10.2", - "react-smooth": "^4.0.0", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" @@ -19031,42 +10475,31 @@ "node": ">=14" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/recharts-scale": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", "dependencies": { "decimal.js-light": "^2.4.1" } }, - "node_modules/recharts/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/recursive-readdir": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", - "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", - "dependencies": { - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=6.0.0" - } + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, + "license": "MIT", "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -19079,289 +10512,87 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "peerDependencies": { - "redux": "^4" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" - }, - "node_modules/regex-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", - "integrity": "sha512-TVILVSz2jY5D47F4mA4MppkBrafEaiUWJO/TcZHEIuI13AqoZMkK1WMA4Om1YkYbTx+9Ki1/tSUXbceyr9saRg==" - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==" - }, - "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "node_modules/renderkid/node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/renderkid/node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" } }, - "node_modules/renderkid/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "license": "MIT", + "peerDependencies": { + "redux": "^4" } }, - "node_modules/renderkid/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true }, - "node_modules/renderkid/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dependencies": { - "boolbase": "^1.0.0" + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, "node_modules/reselect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.7.tgz", - "integrity": "sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A==" + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", "engines": { "node": ">=4" } @@ -19370,91 +10601,11 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, - "node_modules/resolve-url-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", - "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^7.0.35", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=8.9" - }, - "peerDependencies": { - "rework": "1.0.1", - "rework-visit": "1.0.0" - }, - "peerDependenciesMeta": { - "rework": { - "optional": true - }, - "rework-visit": { - "optional": true - } - } - }, - "node_modules/resolve-url-loader/node_modules/picocolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" - }, - "node_modules/resolve-url-loader/node_modules/postcss": { - "version": "7.0.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", - "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", - "dependencies": { - "picocolors": "^0.2.1", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - } - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve.exports": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", - "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -19474,258 +10625,120 @@ "react": ">=16.8" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/robust-predicates": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", - "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" }, "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=10.0.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" } }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup-plugin-terser/node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/sanitize.css": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", - "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" - }, - "node_modules/sass-loader": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", - "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", - "dependencies": { - "klona": "^2.0.4", - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "fibers": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - } + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=v12.22.7" } }, "node_modules/scheduler": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" } }, "node_modules/schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { "node": ">= 10.13.0" @@ -19735,172 +10748,68 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" + "fast-deep-equal": "^3.1.3" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "ajv": "^8.8.2" } }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "peer": true, "dependencies": { "randombytes": "^2.1.0" } }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -19928,34 +10837,32 @@ "node": ">= 0.4" } }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -19967,25 +10874,11 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/shellwords": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "optional": true, - "peer": true - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -20058,70 +10951,84 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" }, "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" } }, "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" }, "node_modules/skmeans": { "version": "0.9.7", "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", - "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==" + "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==", + "license": "MIT" }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/sockjs/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/source-list-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -20130,35 +11037,18 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-loader": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", - "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", - "dependencies": { - "abab": "^2.0.5", - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -20168,195 +11058,105 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead" - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/splaytree": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.2.tgz", - "integrity": "sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A==" - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" - }, - "node_modules/stable": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.14" - } - }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" - }, - "node_modules/static-eval": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", - "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", - "dependencies": { - "escodegen": "^1.8.1" - } - }, - "node_modules/static-eval/node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=4.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/static-eval/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/splaytree": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", + "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=18.20 || >=20" } }, - "node_modules/static-eval/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" + "extend-shallow": "^3.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/static-eval/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/static-eval/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/static-eval/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, "engines": { "node": ">=0.10.0" } }, - "node_modules/static-eval/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", "dependencies": { - "prelude-ls": "~1.1.2" + "escape-string-regexp": "^2.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, "engines": { - "node": ">= 0.8" + "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -20371,53 +11171,20 @@ } }, "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" + "safe-buffer": "~5.1.0" } }, - "node_modules/string-natural-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", - "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -20432,6 +11199,8 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -20444,123 +11213,23 @@ "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "dependencies": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - }, - "engines": { - "node": ">=4" - } + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -20573,6 +11242,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -20580,35 +11251,12 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", - "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", - "engines": { - "node": ">=10" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, + "license": "MIT", "dependencies": { "min-indent": "^1.0.0" }, @@ -20620,168 +11268,55 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-loader": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", - "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/stylehacks": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", - "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", - "dependencies": { - "browserslist": "^4.21.4", - "postcss-selector-parser": "^6.0.4" - }, - "engines": { - "node": "^10 || ^12 || >=14.0" - }, - "peerDependencies": { - "postcss": "^8.2.15" - } - }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "js-tokens": "^9.0.1" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", "dependencies": { "kdbush": "^4.0.2" } }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -20793,6 +11328,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -20800,11 +11336,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" - }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -20815,190 +11346,36 @@ "node": ">=12.0.0" } }, - "node_modules/svgo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", - "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", - "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", - "dependencies": { - "chalk": "^2.4.1", - "coa": "^2.0.2", - "css-select": "^2.0.0", - "css-select-base-adapter": "^0.1.1", - "css-tree": "1.0.0-alpha.37", - "csso": "^4.0.2", - "js-yaml": "^3.13.1", - "mkdirp": "~0.5.1", - "object.values": "^1.1.0", - "sax": "~1.2.4", - "stable": "^0.1.8", - "unquote": "~1.1.1", - "util.promisify": "~1.0.0" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" - }, - "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", - "engines": { - "node": ">=14" - } - }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", - "engines": { - "node": ">= 14" - } + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "peer": true, "engines": { "node": ">=6" - } - }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/tempy": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", - "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", - "dependencies": { - "is-stream": "^2.0.0", - "temp-dir": "^2.0.0", - "type-fest": "^0.16.0", - "unique-string": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, - "engines": { - "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.5.tgz", - "integrity": "sha512-qcwfg4+RZa3YvlFh0qjifnzBHjKGNbtDo9yivMqMFDy9Q6FSaQWSB/j1xKhsoUFJIqDOM3TsN6D5xbrMrFcHbg==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "license": "BSD-2-Clause", + "peer": true, "dependencies": { - "@jridgewell/source-map": "^0.3.2", - "acorn": "^8.5.0", + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -21010,15 +11387,17 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", - "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "license": "MIT", + "peer": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.14", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.0", - "terser": "^5.14.1" + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -21045,20 +11424,9 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true }, "node_modules/text-segmentation": { "version": "1.0.3", @@ -21070,64 +11438,135 @@ "utrie": "^1.0.2" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, "node_modules/tiny-case": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", - "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" }, "node_modules/tiny-invariant": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", - "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } }, "node_modules/tinyqueue": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", - "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -21135,18 +11574,11 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", "dependencies": { "commander": "2" }, @@ -21159,12 +11591,14 @@ "node_modules/topojson-client/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, "node_modules/topojson-server": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", "integrity": "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==", + "license": "ISC", "dependencies": { "commander": "2" }, @@ -21175,55 +11609,38 @@ "node_modules/topojson-server/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" }, "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "punycode": "^2.1.1" + "tldts": "^7.0.5" }, "engines": { - "node": ">=8" + "node": ">=16" } }, - "node_modules/tryer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", - "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -21233,75 +11650,24 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "engines": { - "node": ">=4" - } - }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/turf-jsts": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/turf-jsts/-/turf-jsts-1.2.3.tgz", - "integrity": "sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA==" + "integrity": "sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA==", + "license": "(EDL-1.0 OR EPL-1.0)" }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -21310,141 +11676,39 @@ } }, "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" + "node": ">=4" } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/typescript-eslint": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.0.tgz", - "integrity": "sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "@typescript-eslint/utils": "8.34.0" + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21455,25 +11719,24 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", - "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/type-utils": "8.34.0", - "@typescript-eslint/utils": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21483,23 +11746,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.34.0", + "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", - "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21510,18 +11773,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", - "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21532,16 +11795,17 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", - "integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/utils": "8.34.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21552,13 +11816,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", - "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -21570,22 +11834,21 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", - "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.34.0", - "@typescript-eslint/tsconfig-utils": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21595,20 +11858,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", - "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21619,18 +11882,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", - "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21689,28 +11952,39 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" + "node_modules/typescript-eslint/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" + } + }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" } }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, "node_modules/uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", @@ -21721,92 +11995,31 @@ "react": ">=15.0.0" } }, - "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "engines": { - "node": ">=4" - } + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", "dependencies": { - "crypto-random-string": "^2.0.0" + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unquote": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==" - }, - "node_modules/upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "engines": { - "node": ">=4", - "yarn": "*" + "node": ">=0.10.0" } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -21821,6 +12034,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -21836,19 +12050,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-memo-one": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", @@ -21859,44 +12065,19 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/util.promisify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", - "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.2", - "has-symbols": "^1.0.1", - "object.getownpropertydescriptors": "^2.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" }, "node_modules/utrie": { "version": "1.0.2", @@ -21921,416 +12102,422 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/victory-vendor/node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/vt-pbf": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", - "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", - "dependencies": { - "@mapbox/point-geometry": "0.1.0", - "@mapbox/vector-tile": "^1.3.1", - "pbf": "^3.2.1" - } - }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", - "dependencies": { - "browser-process-hrtime": "^1.0.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", - "dependencies": { - "xml-name-validator": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dependencies": { - "loose-envify": "^1.0.0" + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" } }, - "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "node_modules/victory-vendor/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" + "internmap": "1 - 2" }, "engines": { - "node": ">=10.13.0" + "node": ">=12" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", - "engines": { - "node": ">=10.4" - } - }, - "node_modules/webpack": { - "version": "5.76.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz", - "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==", - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.10.0", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { - "webpack": "bin/webpack.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=10.13.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "webpack-cli": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.3", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">= 12.13.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-tsconfig-paths": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", + "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } } }, - "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" + "node_modules/vite-tsconfig-paths/node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" }, "peerDependencies": { - "ajv": "^8.8.2" + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 12.13.0" + "node": ">=12.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/webpack-dev-server": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", - "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", - "dependencies": { - "@types/bonjour": "^3.5.9", - "@types/connect-history-api-fallback": "^1.3.5", - "@types/express": "^4.17.13", - "@types/serve-index": "^1.9.1", - "@types/serve-static": "^1.13.10", - "@types/sockjs": "^0.3.33", - "@types/ws": "^8.5.5", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.0.11", - "chokidar": "^3.5.3", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", - "graceful-fs": "^4.2.6", - "html-entities": "^2.3.2", - "http-proxy-middleware": "^2.0.3", - "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.1.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.13.0" + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" }, "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" + "vitest": "vitest.mjs" }, "engines": { - "node": ">= 12.13.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" }, "peerDependenciesMeta": { - "webpack": { + "@edge-runtime/vm": { "optional": true }, - "webpack-cli": { + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { "optional": true } } }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/vitest-canvas-mock": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-0.3.3.tgz", + "integrity": "sha512-3P968tYBpqYyzzOaVtqnmYjqbe13576/fkjbDEJSfQAkHtC5/UjuRHOhFEN/ZV5HVZIkaROBUWgazDKJ+Ibw+Q==", + "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "jest-canvas-mock": "~2.5.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "vitest": "*" } }, - "node_modules/webpack-dev-server/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=18" } }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" } }, - "node_modules/webpack-manifest-plugin": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", - "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, "dependencies": { - "tapable": "^2.0.0", - "webpack-sources": "^2.2.0" + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" }, "engines": { - "node": ">=12.22.0" - }, - "peerDependencies": { - "webpack": "^4.44.2 || ^5.47.0" + "node": ">=10.13.0" } }, - "node_modules/webpack-manifest-plugin/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=0.10.0" + "node": ">=20" } }, - "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", - "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "node_modules/webpack": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", + "license": "MIT", + "peer": true, "dependencies": { - "source-list-map": "^2.0.1", - "source-map": "^0.6.1" + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, - "engines": { - "node": ">=10.13.0" + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" - }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -22343,77 +12530,43 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "dependencies": { - "iconv-lite": "0.4.24" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" - }, - "node_modules/whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" - }, "node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - }, - "engines": { - "node": ">=10" + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -22443,33 +12596,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", @@ -22489,9 +12615,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -22509,313 +12635,38 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/workbox-background-sync": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", - "integrity": "sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==", - "dependencies": { - "idb": "^7.0.1", - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-broadcast-update": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz", - "integrity": "sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-build": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.6.0.tgz", - "integrity": "sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==", - "dependencies": { - "@apideck/better-ajv-errors": "^0.3.1", - "@babel/core": "^7.11.1", - "@babel/preset-env": "^7.11.0", - "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^11.2.1", - "@rollup/plugin-replace": "^2.4.1", - "@surma/rollup-plugin-off-main-thread": "^2.2.3", - "ajv": "^8.6.0", - "common-tags": "^1.8.0", - "fast-json-stable-stringify": "^2.1.0", - "fs-extra": "^9.0.1", - "glob": "^7.1.6", - "lodash": "^4.17.20", - "pretty-bytes": "^5.3.0", - "rollup": "^2.43.1", - "rollup-plugin-terser": "^7.0.0", - "source-map": "^0.8.0-beta.0", - "stringify-object": "^3.3.0", - "strip-comments": "^2.0.1", - "tempy": "^0.6.0", - "upath": "^1.2.0", - "workbox-background-sync": "6.6.0", - "workbox-broadcast-update": "6.6.0", - "workbox-cacheable-response": "6.6.0", - "workbox-core": "6.6.0", - "workbox-expiration": "6.6.0", - "workbox-google-analytics": "6.6.0", - "workbox-navigation-preload": "6.6.0", - "workbox-precaching": "6.6.0", - "workbox-range-requests": "6.6.0", - "workbox-recipes": "6.6.0", - "workbox-routing": "6.6.0", - "workbox-strategies": "6.6.0", - "workbox-streams": "6.6.0", - "workbox-sw": "6.6.0", - "workbox-window": "6.6.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", - "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", - "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", - "leven": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "ajv": ">=8" - } - }, - "node_modules/workbox-build/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/workbox-build/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/workbox-build/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/workbox-build/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", "dependencies": { - "whatwg-url": "^7.0.0" + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/workbox-build/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/workbox-build/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "node_modules/workbox-build/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/workbox-cacheable-response": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.6.0.tgz", - "integrity": "sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==", - "deprecated": "workbox-background-sync@6.6.0", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.6.0.tgz", - "integrity": "sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==" - }, - "node_modules/workbox-expiration": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.6.0.tgz", - "integrity": "sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==", - "dependencies": { - "idb": "^7.0.1", - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-google-analytics": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz", - "integrity": "sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==", - "dependencies": { - "workbox-background-sync": "6.6.0", - "workbox-core": "6.6.0", - "workbox-routing": "6.6.0", - "workbox-strategies": "6.6.0" - } - }, - "node_modules/workbox-navigation-preload": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.6.0.tgz", - "integrity": "sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-precaching": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.6.0.tgz", - "integrity": "sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==", - "dependencies": { - "workbox-core": "6.6.0", - "workbox-routing": "6.6.0", - "workbox-strategies": "6.6.0" - } - }, - "node_modules/workbox-range-requests": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.6.0.tgz", - "integrity": "sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-recipes": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.6.0.tgz", - "integrity": "sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==", - "dependencies": { - "workbox-cacheable-response": "6.6.0", - "workbox-core": "6.6.0", - "workbox-expiration": "6.6.0", - "workbox-precaching": "6.6.0", - "workbox-routing": "6.6.0", - "workbox-strategies": "6.6.0" - } - }, - "node_modules/workbox-routing": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.6.0.tgz", - "integrity": "sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-strategies": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.6.0.tgz", - "integrity": "sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==", - "dependencies": { - "workbox-core": "6.6.0" - } - }, - "node_modules/workbox-streams": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.6.0.tgz", - "integrity": "sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==", - "dependencies": { - "workbox-core": "6.6.0", - "workbox-routing": "6.6.0" - } - }, - "node_modules/workbox-sw": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.6.0.tgz", - "integrity": "sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==" - }, - "node_modules/workbox-webpack-plugin": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.6.0.tgz", - "integrity": "sha512-xNZIZHalboZU66Wa7x1YkjIqEy1gTR+zPM+kjrYJzqN7iurYZBctBLISyScjhkJKYuRrZUP0iqViZTh8rS0+3A==", - "dependencies": { - "fast-json-stable-stringify": "^2.1.0", - "pretty-bytes": "^5.4.1", - "upath": "^1.2.0", - "webpack-sources": "^1.4.3", - "workbox-build": "6.6.0" + "bin": { + "why-is-node-running": "cli.js" }, "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "webpack": "^4.4.0 || ^5.9.0" + "node": ">=8" } }, - "node_modules/workbox-webpack-plugin/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", - "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", - "dependencies": { - "source-list-map": "^2.0.0", - "source-map": "~0.6.1" - } - }, - "node_modules/workbox-window": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.6.0.tgz", - "integrity": "sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==", - "dependencies": { - "@types/trusted-types": "^2.0.2", - "workbox-core": "6.6.0" - } - }, "node_modules/worker-loader": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz", "integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==", + "license": "MIT", "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -22831,11 +12682,31 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/worker-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -22848,51 +12719,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -22904,32 +12742,35 @@ } }, "node_modules/xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -22938,9 +12779,10 @@ } }, "node_modules/yup": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", - "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -22952,6 +12794,7 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=12.20" }, @@ -22960,20 +12803,25 @@ } }, "node_modules/zustand": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.1.tgz", - "integrity": "sha512-EVyo/eLlOTcJm/X5M00rwtbYFXwRVTaRteSvhtbTZUCQFJkNfIyHPiJ6Ke68MSWzcKHpPzvqNH4gC2ZS/sbNqw==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", "dependencies": { - "use-sync-external-store": "1.2.0" + "use-sync-external-store": "^1.2.2" }, "engines": { "node": ">=12.7.0" }, "peerDependencies": { - "immer": ">=9.0", + "@types/react": ">=16.8", + "immer": ">=9.0.6", "react": ">=16.8" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "immer": { "optional": true }, diff --git a/webapp/package.json b/webapp/package.json index 20508e8b7..d8b6dc622 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -6,7 +6,7 @@ "@emotion/react": "^11.9.0", "@emotion/styled": "^11.13.0", "@hookform/error-message": "^2.0.1", - "@material-table/core": "^6.1.4", + "@material-table/core": "6.2.11", "@mui/icons-material": "^6.1.4", "@mui/material": "^6.1.4", "@mui/styles": "^6.1.4", @@ -22,15 +22,15 @@ "date-fns-tz": "^3.2.0", "dayjs": "^1.11.7", "env-cmd": "^10.1.0", - "eslint-config-react-app": "^7.0.1", "file-saver": "^2.0.5", "formik": "^2.4.6", "geojson": "^0.5.0", "html-to-image": "^1.11.11", "jest-fetch-mock": "^3.0.3", "js-sha256": "^0.11.0", - "jspdf": "^2.5.2", + "jspdf": "^4.1.0", "jszip": "^3.10.1", + "jwt-decode": "^4.0.0", "keycloak-js": "26.0.5", "luxon": "^3.5.0", "mapbox-gl": "^2.9.1", @@ -45,7 +45,6 @@ "react-perfect-scrollbar": "^1.5.8", "react-redux": "^8.0.5", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "react-secure-storage": "^1.3.2", "react-spinners": "^0.11.0", "react-to-print": "^3.0.1", @@ -57,19 +56,18 @@ "yup": "^1.4.0" }, "scripts": { - "start": "react-scripts start", - "start-pc": "set PORT=3001&& react-scripts start", - "build": "react-scripts build", + "start": "vite", + "build": "tsc -b && vite build", "build:dev": "env-cmd -f .env.development npm run build", "build:test": "env-cmd -f .env.test npm run build", "build:prod": "env-cmd -f .env.production npm run build", "build:all": "npm run build:dev && npm run build:test && npm run build:prod", - "test": "react-scripts test", - "test:coverage": "react-scripts test --coverage --watchAll", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": "react-app" + "test": "npm run typecheck && vitest", + "test:coverage": "npm run typecheck && vitest run --coverage", + "typecheck": "tsc --noEmit", + "dev": "vite", + "lint": "eslint .", + "preview": "vite preview" }, "browserslist": { "production": [ @@ -87,22 +85,21 @@ ] }, "devDependencies": { - "@eslint/js": "^9.28.0", + "@eslint/js": "^9.39.2", "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^30.0.0", + "@types/node": "^22.15.19", "@types/react": "^17.0.0", - "@types/react-dom": "^18.2.7", - "eslint": "^8.57.1", - "jest-canvas-mock": "^2.5.2", - "typescript": "^4.9.5", - "typescript-eslint": "^8.34.0" - }, - "jest": { - "transformIgnorePatterns": [ - "node_modules/(?!keycloak-js)" - ], - "moduleNameMapper": { - "\\.(css|less|scss|sass)$": "identity-obj-proxy", - "worker-loader!mapbox-gl/dist/mapbox-gl-csp-worker": "/__mocks__/worker-loader.js" - } + "@types/react-dom": "^17.0.0", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^3.0.0", + "eslint": "^9.39.2", + "jsdom": "^27.4.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^7.0.0", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^3.0.0", + "vitest-canvas-mock": "^0.3.3" } } diff --git a/webapp/public/index.html b/webapp/public/index.html deleted file mode 100644 index d6e752bd3..000000000 --- a/webapp/public/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - CV Manager - - - -
- - diff --git a/webapp/sample.env.development.local b/webapp/sample.env.development.local new file mode 100644 index 000000000..ba3a4bead --- /dev/null +++ b/webapp/sample.env.development.local @@ -0,0 +1,41 @@ +# used to determine where to install build +BUILD_PATH='./build' + +############## REQUIRED ############## +DOCKER_HOST_IP= +VITE_MAPBOX_TOKEN= + +# used to swap endpoints for various environments or leave blank +VITE_ENV="dev" + +# base url for api +VITE_GATEWAY_BASE_URL="http://localhost:8081" + +# base url or IP for keycloak +VITE_KEYCLOAK_URL="http://${DOCKER_HOST_IP}:8084/" +VITE_KEYCLOAK_REALM="cvmanager" +VITE_KEYCLOAK_CLIENT_ID=cvmanager-gui + +VITE_COUNT_MESSAGE_TYPES='BSM,SSM,SPAT,SRM,MAP' +VITE_DOT_NAME="CDOT" + +VITE_CVIZ_API_SERVER_URL='http://localhost:8089' +VITE_CVIZ_API_WS_URL='ws://localhost:8089' + +# initial mapbox view +VITE_MAPBOX_INIT_LATITUDE=39.7392 +VITE_MAPBOX_INIT_LONGITUDE=-104.9903 +VITE_MAPBOX_INIT_ZOOM=10 + +# 'true' to enable a feature and 'false' to disable it: +VITE_ENABLE_RSU_FEATURES='true' +VITE_ENABLE_INTERSECTION_FEATURES='true' +VITE_ENABLE_WZDX_FEATURES='true' +VITE_ENABLE_HAAS_FEATURES='true' + +# Webapp themes: dark +# base theme is used by default, dark theme is used if browser is set to dark mode +VITE_WEBAPP_THEME_LIGHT="dark" +# if not set, defaults to 'light' +VITE_WEBAPP_THEME_DARK="dark" +# if not set, defaults to 'dark' \ No newline at end of file diff --git a/webapp/sample.env.local b/webapp/sample.env.local deleted file mode 100644 index 920aff320..000000000 --- a/webapp/sample.env.local +++ /dev/null @@ -1,29 +0,0 @@ -# used to determine where to install build -BUILD_PATH='./build' - -# mapbox access token -REACT_APP_MAPBOX_TOKEN="" -REACT_APP_CVIZ_MAPBOX_TOKEN="" -REACT_APP_CVIZ_MAPBOX_STYLE_URL="" - -# used to swap endpoints for various environments or leave blank -REACT_APP_ENV="dev" - -# base url for api -REACT_APP_GATEWAY_BASE_URL="http://cvmanager.local.com:8081" - -# base url or IP for keycloak -REACT_APP_KEYCLOAK_URL="http://cvmanager.auth.com:8084/" -REACT_APP_KEYCLOAK_REALM="cvmanager" - -REACT_APP_COUNT_MESSAGE_TYPES='BSM,SSM,SPAT,SRM,MAP,PSM' -REACT_APP_VIEWER_MESSAGE_TYPES='BSM,PSM' -DOT_NAME="CDOT" - -REACT_APP_CVIZ_API_SERVER_URL='http://localhost:8085' -REACT_APP_CVIZ_API_WS_URL='ws://localhost:8085' - -# initial mapbox view -REACT_APP_MAPBOX_INIT_LATITUDE="39.7392" -REACT_APP_MAPBOX_INIT_LONGITUDE="-104.9903" -REACT_APP_MAPBOX_INIT_ZOOM="10" \ No newline at end of file diff --git a/webapp/src/App.test.tsx b/webapp/src/App.test.tsx index 232537c4f..d5b636c44 100644 --- a/webapp/src/App.test.tsx +++ b/webapp/src/App.test.tsx @@ -7,30 +7,48 @@ import { testTheme } from './styles' import { setupStore } from './store' import { replaceChaoticIds } from './utils/test-utils' -jest.mock('./EnvironmentVars', () => ({ - WEBAPP_THEME_LIGHT: 'light', - WEBAPP_THEME_DARK: 'dark', - getMessageTypes: jest.fn(() => ['BSM']), - getMapboxInitViewState: jest.fn(() => ({ - latitude: 39.7392, - longitude: -104.9903, - zoom: 10, - })), - KEYCLOAK_HOST_URL: 'https://keycloak.example.com', - KEYCLOAK_CLIENT_ID: 'keycloak-client-id', - KEYCLOAK_REALM: 'keycloak-realm', +import { vi } from 'vitest' + +vi.mock('./EnvironmentVars', () => ({ + default: { + WEBAPP_THEME_LIGHT: 'light', + WEBAPP_THEME_DARK: 'dark', + getMapboxInitViewState: vi.fn(() => ({ + latitude: 39.7392, + longitude: -104.9903, + zoom: 10, + })), + KEYCLOAK_HOST_URL: 'https://keycloak.example.com', + KEYCLOAK_CLIENT_ID: 'keycloak-client-id', + KEYCLOAK_REALM: 'keycloak-realm', + }, +})) + +vi.mock('@react-keycloak/web', () => ({ + useKeycloak: () => ({ + keycloak: { + authenticated: false, + login: vi.fn(), + logout: vi.fn(), + register: vi.fn(), + accountManagement: vi.fn(), + loadUserProfile: vi.fn(), + }, + initialized: true, + }), + ReactKeycloakProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, })) beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation((query) => ({ + value: vi.fn().mockImplementation((query) => ({ matches: query === '(prefers-color-scheme: dark)', media: query, onchange: null, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), })), }) }) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index c4a4090b3..20051956c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react' +import { useEffect, useMemo } from 'react' import './App.css' import { useSelector, useDispatch } from 'react-redux' import { diff --git a/webapp/src/Dashboard.tsx b/webapp/src/Dashboard.tsx index 80549a2e1..855d150bd 100644 --- a/webapp/src/Dashboard.tsx +++ b/webapp/src/Dashboard.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { css } from '@emotion/react' import RingLoader from 'react-spinners/RingLoader' import Header from './components/Header' @@ -9,8 +8,7 @@ import Tabs, { TabItem } from './components/Tabs' import Map from './pages/Map' import './App.css' import { useSelector } from 'react-redux' -import { selectAuthLoginData, selectLoadingGlobal } from './generalSlices/userSlice' -import { SecureStorageManager } from './managers' +import { selectAuthLoginData, selectIsAdminOrAbove, selectLoadingGlobal } from './generalSlices/userSlice' import keycloak from './keycloak-config' import { Routes, Route, Navigate } from 'react-router-dom' import IntersectionMapView from './pages/IntersectionMapView' @@ -25,6 +23,7 @@ const Dashboard = () => { const theme = useTheme() const authLoginData = useSelector(selectAuthLoginData) const loadingGlobal = useSelector(selectLoadingGlobal) + const isAdmin = useSelector(selectIsAdminOrAbove) return ( @@ -36,7 +35,7 @@ const Dashboard = () => { - {SecureStorageManager.getUserRole() !== 'admin' ? <> : } + {isAdmin ? : <>} diff --git a/webapp/src/EnvironmentVars.test.tsx b/webapp/src/EnvironmentVars.test.tsx index 079897594..e012b508c 100644 --- a/webapp/src/EnvironmentVars.test.tsx +++ b/webapp/src/EnvironmentVars.test.tsx @@ -1,15 +1,9 @@ import EnvironmentVars from './EnvironmentVars' -it('returns message types', () => { - process.env.REACT_APP_COUNT_MESSAGE_TYPES = 'type1, type2, type3' - const expectedMessageTypes = ['type1', 'type2', 'type3'] - expect(EnvironmentVars.getMessageTypes()).toEqual(expectedMessageTypes) -}) - it('returns mapbox initial view state', () => { - process.env.REACT_APP_MAPBOX_INIT_LATITUDE = '12.34' - process.env.REACT_APP_MAPBOX_INIT_LONGITUDE = '56.78' - process.env.REACT_APP_MAPBOX_INIT_ZOOM = '9.0' + process.env.VITE_MAPBOX_INIT_LATITUDE = '12.34' + process.env.VITE_MAPBOX_INIT_LONGITUDE = '56.78' + process.env.VITE_MAPBOX_INIT_ZOOM = '9.0' const expectedViewState = { latitude: 12.34, longitude: 56.78, diff --git a/webapp/src/EnvironmentVars.tsx b/webapp/src/EnvironmentVars.tsx index 0a6373f3a..a94efbf11 100644 --- a/webapp/src/EnvironmentVars.tsx +++ b/webapp/src/EnvironmentVars.tsx @@ -1,19 +1,10 @@ class EnvironmentVars { static getBaseApiUrl() { - return process.env.REACT_APP_GATEWAY_BASE_URL?.replace(/\/$/, '') // remove trailing slash - } - - static getMessageTypes() { - const COUNT_MESSAGE_TYPES = process.env.REACT_APP_COUNT_MESSAGE_TYPES - if (!COUNT_MESSAGE_TYPES) { - return [] - } - const messageTypes = COUNT_MESSAGE_TYPES.split(',').map((item) => item.trim()) - return messageTypes + return process.env.VITE_GATEWAY_BASE_URL?.replace(/\/$/, '') // remove trailing slash } static getMessageViewerTypes() { - const VIEWER_MESSAGE_TYPES = process.env.REACT_APP_VIEWER_MESSAGE_TYPES + const VIEWER_MESSAGE_TYPES = process.env.VITE_VIEWER_MESSAGE_TYPES if (!VIEWER_MESSAGE_TYPES) { return ['BSM'] // default to BSM if not set } @@ -22,9 +13,9 @@ class EnvironmentVars { } static getMapboxInitViewState() { - const MAPBOX_INIT_LATITUDE = Number(process.env.REACT_APP_MAPBOX_INIT_LATITUDE) - const MAPBOX_INIT_LONGITUDE = Number(process.env.REACT_APP_MAPBOX_INIT_LONGITUDE) - const MAPBOX_INIT_ZOOM = Number(process.env.REACT_APP_MAPBOX_INIT_ZOOM) + const MAPBOX_INIT_LATITUDE = Number(process.env.VITE_MAPBOX_INIT_LATITUDE) + const MAPBOX_INIT_LONGITUDE = Number(process.env.VITE_MAPBOX_INIT_LONGITUDE) + const MAPBOX_INIT_ZOOM = Number(process.env.VITE_MAPBOX_INIT_ZOOM) const viewState = { latitude: MAPBOX_INIT_LATITUDE, @@ -35,46 +26,38 @@ class EnvironmentVars { return viewState } - static MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_TOKEN - static CVIZ_API_SERVER_URL = process.env.REACT_APP_CVIZ_API_SERVER_URL?.replace(/\/$/, '') // remove trailing slash - static CVIZ_API_WS_URL = process.env.REACT_APP_CVIZ_API_WS_URL?.replace(/\/$/, '') // remove trailing slash - static KEYCLOAK_HOST_URL = process.env.REACT_APP_KEYCLOAK_URL - static KEYCLOAK_REALM = process.env.REACT_APP_KEYCLOAK_REALM - static KEYCLOAK_CLIENT_ID = process.env.REACT_APP_KEYCLOAK_CLIENT_ID - static DOT_NAME = process.env.REACT_APP_DOT_NAME - static ENABLE_RSU_FEATURES = process.env.REACT_APP_ENABLE_RSU_FEATURES !== 'false' - static ENABLE_INTERSECTION_FEATURES = process.env.REACT_APP_ENABLE_INTERSECTION_FEATURES !== 'false' - static ENABLE_WZDX_FEATURES = process.env.REACT_APP_ENABLE_WZDX_FEATURES !== 'false' - static ENABLE_MOOVE_AI_FEATURES = process.env.REACT_APP_ENABLE_MOOVE_AI_FEATURES !== 'false' - static ENABLE_HAAS_FEATURES = process.env.REACT_APP_ENABLE_HAAS_FEATURES !== 'false' - static WEBAPP_THEME_LIGHT = process.env.REACT_APP_WEBAPP_THEME_LIGHT - static WEBAPP_THEME_DARK = process.env.REACT_APP_WEBAPP_THEME_DARK + static MAPBOX_TOKEN = process.env.VITE_MAPBOX_TOKEN + static CVIZ_API_SERVER_URL = process.env.VITE_CVIZ_API_SERVER_URL?.replace(/\/$/, '') // remove trailing slash + static CVIZ_API_WS_URL = process.env.VITE_CVIZ_API_WS_URL?.replace(/\/$/, '') // remove trailing slash + static KEYCLOAK_HOST_URL = process.env.VITE_KEYCLOAK_URL + static KEYCLOAK_REALM = process.env.VITE_KEYCLOAK_REALM + static KEYCLOAK_CLIENT_ID = process.env.VITE_KEYCLOAK_CLIENT_ID + static DOT_NAME = process.env.VITE_DOT_NAME + static ENABLE_RSU_FEATURES = process.env.VITE_ENABLE_RSU_FEATURES !== 'false' + static ENABLE_INTERSECTION_FEATURES = process.env.VITE_ENABLE_INTERSECTION_FEATURES !== 'false' + static ENABLE_WZDX_FEATURES = process.env.VITE_ENABLE_WZDX_FEATURES !== 'false' + static ENABLE_HAAS_FEATURES = process.env.VITE_ENABLE_HAAS_FEATURES !== 'false' + static WEBAPP_THEME_LIGHT = process.env.VITE_WEBAPP_THEME_LIGHT + static WEBAPP_THEME_DARK = process.env.VITE_WEBAPP_THEME_DARK static cvmanagerBaseEndpoint = `${this.getBaseApiUrl()}` - static rsuInfoEndpoint = `${this.getBaseApiUrl()}/rsuinfo` + static rsuInfoPath = '/devices/rsus/info' static rsuOnlineEndpoint = `${this.getBaseApiUrl()}/rsu-online-status` static rsuCountsEndpoint = `${this.getBaseApiUrl()}/rsucounts` static rsuCommandEndpoint = `${this.getBaseApiUrl()}/rsu-command` + static rsuUpgradeEndpoint = `${this.CVIZ_API_SERVER_URL}/devices/rsus/upgrade` static wzdxEndpoint = `${this.getBaseApiUrl()}/wzdx-feed` static rsuGeoQueryEndpoint = `${this.getBaseApiUrl()}/rsu-config-geo-query` static rsuMsgFwdQueryEndpoint = `${this.getBaseApiUrl()}/rsu-msgfwd-query` + static rsuMsgFwdFetchEndpoint = `${this.getBaseApiUrl()}/rsu-msgfwd-fetch` static geoMsgDataEndpoint = `${this.getBaseApiUrl()}/rsu-geo-msg-data` - static mooveAiDataEndpoint = `${this.getBaseApiUrl()}/moove-ai-data` - static issScmsStatusEndpoint = `${this.getBaseApiUrl()}/iss-scms-status` static ssmSrmEndpoint = `${this.getBaseApiUrl()}/rsu-ssm-srm-data` - static authEndpoint = `${this.getBaseApiUrl()}/user-auth` - static adminAddRsu = `${this.getBaseApiUrl()}/admin-new-rsu` - static adminRsu = `${this.getBaseApiUrl()}/admin-rsu` - static adminAddIntersection = `${this.getBaseApiUrl()}/admin-new-intersection` - static adminIntersection = `${this.getBaseApiUrl()}/admin-intersection` - static adminAddUser = `${this.getBaseApiUrl()}/admin-new-user` - static adminUser = `${this.getBaseApiUrl()}/admin-user` static adminNotification = `${this.getBaseApiUrl()}/admin-notification` static adminAddNotification = `${this.getBaseApiUrl()}/admin-new-notification` static adminAddOrg = `${this.getBaseApiUrl()}/admin-new-org` static adminOrg = `${this.getBaseApiUrl()}/admin-org` - static contactSupport = `${this.getBaseApiUrl()}/contact-support` - static rsuErrorSummary = `${this.getBaseApiUrl()}/rsu-error-summary` + static adminOrgTimDeposit = `${this.getBaseApiUrl()}/admin-org-tim-deposit` + static adminOrgSnmpMonitoring = `${this.getBaseApiUrl()}/admin-org-snmp-monitoring` } export default EnvironmentVars diff --git a/webapp/src/__snapshots__/App.test.tsx.snap b/webapp/src/__snapshots__/App.test.tsx.snap index cebedd828..2d03e6231 100644 --- a/webapp/src/__snapshots__/App.test.tsx.snap +++ b/webapp/src/__snapshots__/App.test.tsx.snap @@ -1,52 +1,55 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`should take a snapshot 1`] = `
-
-
-
+
+
+
- -

- CV Manager -

+ +

+ CV Manager +

+
+
+
-
-
+
+
+
-
-
+ data-rht-toaster="" + style="position: fixed; z-index: 9999; top: 16px; left: 16px; right: 16px; bottom: 16px; pointer-events: none;" + />
-
`; diff --git a/webapp/src/apis/api-helper.test.ts b/webapp/src/apis/api-helper.test.ts index 80efdb901..5c0e69ebc 100644 --- a/webapp/src/apis/api-helper.test.ts +++ b/webapp/src/apis/api-helper.test.ts @@ -50,6 +50,12 @@ it('Test fetch with codes request Error', async () => { expect(actualResponse).toEqual(null) }) +it('Test fetch with non-ok response', async () => { + fetchMock.mockResponseOnce('NOT OK', { status: 400 }) + const actualResponse = await ApiHelper._getData({ url: 'https://test.com', token: 'testToken' }) + expect(actualResponse).toEqual(null) +}) + it('Test post request', async () => { const expectedResponse = { data: 'Test JSON' } fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) diff --git a/webapp/src/apis/api-helper.ts b/webapp/src/apis/api-helper.ts index 2f9f95f64..69c4c745f 100644 --- a/webapp/src/apis/api-helper.ts +++ b/webapp/src/apis/api-helper.ts @@ -46,6 +46,11 @@ class ApiHelper { }, }) + if (!resp.ok) { + console.error(`Error in _getData: ${resp.status} ${resp.statusText}`) + return null + } + const response = await resp.json() return response } catch (err) { diff --git a/webapp/src/apis/auth-api.test.ts b/webapp/src/apis/auth-api.test.ts index 8bd9cf6f4..86f68cf8e 100644 --- a/webapp/src/apis/auth-api.test.ts +++ b/webapp/src/apis/auth-api.test.ts @@ -1,64 +1,385 @@ +import { jwtDecode } from 'jwt-decode' import AuthApi from './auth-api' import EnvironmentVars from '../EnvironmentVars' +import { AuthToken } from '../models/AuthToken' +import { vi } from 'vitest' -it('Test AuthApi logIn method', async () => { - const testToken = 'testToken' - const expectedFetchResponse = { content: 'content' } - const fetchResponse = { - json: jest.fn().mockResolvedValue(expectedFetchResponse), - status: 200, - headers: new Headers(), - ok: false, - redirected: false, - statusText: 'Success', - } - - global.fetch = jest.fn().mockResolvedValue(fetchResponse) - - const response = await AuthApi.logIn(testToken) - - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(global.fetch).toHaveBeenCalledWith(EnvironmentVars.authEndpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: testToken, - }, +// Mock jwt-decode +vi.mock('jwt-decode') + +// Mock EnvironmentVars +vi.mock('../EnvironmentVars', () => ({ + default: { + KEYCLOAK_HOST_URL: 'http://localhost:8084/', + KEYCLOAK_REALM: 'cvmanager', + }, +})) + +describe('AuthApi', () => { + beforeEach(() => { + vi.clearAllMocks() }) - expect(response).toEqual({ - json: { - content: 'content', - }, - status: 200, + + describe('parseToken', () => { + it('should successfully decode a valid JWT token', () => { + const mockToken = 'valid.jwt.token' + const mockDecodedToken: AuthToken = { + exp: 1770396901, + iat: 1770395101, + jti: '1b921158-2396-461f-8b93-cee772503c2e', + iss: 'http://localhost:8084/realms/cvmanager', + aud: 'account', + sub: 'fc3d8729-8526-4aaa-805b-d64bf3b93860', + typ: 'Bearer', + azp: 'cvmanager-gui', + sid: '75f26b63-1df5-4b9a-953b-61fd72bd8c6b', + acr: '1', + 'allowed-origins': ['http://localhost:3000'], + realm_access: { roles: ['offline_access'] }, + resource_access: { account: { roles: ['manage-account'] } }, + scope: 'openid email profile', + email_verified: false, + name: 'Test User', + preferred_username: 'test@gmail.com', + given_name: 'Test', + family_name: 'User', + cvmanager_data: { + super_user: '1', + organizations: [{ org: 'Test Org', role: 'admin' }], + user_created_timestamp: 1746773527283, + }, + email: 'test@gmail.com', + } + + ;(jwtDecode as any).mockReturnValue(mockDecodedToken) + + const result = AuthApi.parseToken(mockToken) + + expect(jwtDecode).toHaveBeenCalledWith(mockToken) + expect(result).toEqual(mockDecodedToken) + }) + + it('should throw error when JWT token is invalid', () => { + const invalidToken = 'invalid.token' + const mockError = new Error('Invalid token') + + ;(jwtDecode as any).mockImplementation(() => { + throw mockError + }) + + expect(() => AuthApi.parseToken(invalidToken)).toThrow('Invalid JWT token') + expect(jwtDecode).toHaveBeenCalledWith(invalidToken) + }) }) -}) -it('Test AuthApi logIn method with non-200 response', async () => { - const testToken = 'testToken' - const expectedFetchResponse = { error: 'Unauthorized' } - const expectedResponse = { - json: jest.fn().mockResolvedValue(expectedFetchResponse), - status: 401, - headers: new Headers(), - ok: false, - redirected: false, - statusText: 'Unauthorized', - } - - global.fetch = jest.fn().mockResolvedValue(expectedResponse) - - try { - const response = await AuthApi.logIn(testToken) - } catch (error) { - expect(error).toEqual(expectedResponse) - } - - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(global.fetch).toHaveBeenCalledWith(EnvironmentVars.authEndpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: testToken, - }, + describe('verifyToken', () => { + it('should return true when Keycloak userinfo endpoint returns 200', async () => { + const mockToken = 'valid.token' + const mockResponse = { + ok: true, + status: 200, + } + + global.fetch = vi.fn().mockResolvedValue(mockResponse) + + const result = await AuthApi.verifyToken(mockToken) + + expect(global.fetch).toHaveBeenCalledWith( + `${EnvironmentVars.KEYCLOAK_HOST_URL}/realms/${EnvironmentVars.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${mockToken}`, + }, + } + ) + expect(result).toBe(true) + }) + + it('should return false when Keycloak userinfo endpoint returns 401', async () => { + const mockToken = 'invalid.token' + const mockResponse = { + ok: false, + status: 401, + } + + global.fetch = vi.fn().mockResolvedValue(mockResponse) + + const result = await AuthApi.verifyToken(mockToken) + + expect(global.fetch).toHaveBeenCalledWith( + `${EnvironmentVars.KEYCLOAK_HOST_URL}/realms/${EnvironmentVars.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${mockToken}`, + }, + } + ) + expect(result).toBe(false) + }) + + it('should return false when fetch throws an error', async () => { + const mockToken = 'valid.token' + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await AuthApi.verifyToken(mockToken) + + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith('Token verification failed:', expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('getUserAuthResponse', () => { + it('should convert AuthToken to UserAuthResponse format', () => { + const mockAuthToken: AuthToken = { + exp: 1770396901, + iat: 1770395101, + jti: '1b921158-2396-461f-8b93-cee772503c2e', + iss: 'http://localhost:8084/realms/cvmanager', + aud: 'account', + sub: 'fc3d8729-8526-4aaa-805b-d64bf3b93860', + typ: 'Bearer', + azp: 'cvmanager-gui', + sid: '75f26b63-1df5-4b9a-953b-61fd72bd8c6b', + acr: '1', + 'allowed-origins': ['http://localhost:3000'], + realm_access: { roles: ['offline_access'] }, + resource_access: { account: { roles: ['manage-account'] } }, + scope: 'openid email profile', + email_verified: false, + name: 'Test User', + preferred_username: 'test@gmail.com', + given_name: 'Test', + family_name: 'User', + cvmanager_data: { + super_user: '1', + organizations: [ + { org: 'Test Org', role: 'admin' }, + { org: 'Test Org 2', role: 'user' }, + ], + user_created_timestamp: 1746773527283, + }, + email: 'test@gmail.com', + } + + const result = AuthApi.getUserAuthResponse(mockAuthToken) + + expect(result).toEqual({ + email: 'test@gmail.com', + first_name: 'Test', + last_name: 'User', + name: 'Test User', + super_user: true, + organizations: [ + { organization: 'Test Org', role: 'ADMIN' }, + { organization: 'Test Org 2', role: 'USER' }, + ], + }) + }) + + it('should convert super_user "0" to false', () => { + const mockAuthToken: AuthToken = { + exp: 1770396901, + iat: 1770395101, + jti: '1b921158-2396-461f-8b93-cee772503c2e', + iss: 'http://localhost:8084/realms/cvmanager', + aud: 'account', + sub: 'fc3d8729-8526-4aaa-805b-d64bf3b93860', + typ: 'Bearer', + azp: 'cvmanager-gui', + sid: '75f26b63-1df5-4b9a-953b-61fd72bd8c6b', + acr: '1', + 'allowed-origins': ['http://localhost:3000'], + realm_access: { roles: ['offline_access'] }, + resource_access: { account: { roles: ['manage-account'] } }, + scope: 'openid email profile', + email_verified: false, + name: 'Regular User', + preferred_username: 'user@gmail.com', + given_name: 'Regular', + family_name: 'User', + cvmanager_data: { + super_user: '0', + organizations: [{ org: 'Test Org', role: 'user' }], + user_created_timestamp: 1746773527283, + }, + email: 'user@gmail.com', + } + + const result = AuthApi.getUserAuthResponse(mockAuthToken) + + expect(result.super_user).toBe(false) + }) + + it('should handle empty organizations array', () => { + const mockAuthToken: AuthToken = { + exp: 1770396901, + iat: 1770395101, + jti: '1b921158-2396-461f-8b93-cee772503c2e', + iss: 'http://localhost:8084/realms/cvmanager', + aud: 'account', + sub: 'fc3d8729-8526-4aaa-805b-d64bf3b93860', + typ: 'Bearer', + azp: 'cvmanager-gui', + sid: '75f26b63-1df5-4b9a-953b-61fd72bd8c6b', + acr: '1', + 'allowed-origins': ['http://localhost:3000'], + realm_access: { roles: ['offline_access'] }, + resource_access: { account: { roles: ['manage-account'] } }, + scope: 'openid email profile', + email_verified: false, + name: 'Test User', + preferred_username: 'test@gmail.com', + given_name: 'Test', + family_name: 'User', + cvmanager_data: { + super_user: '1', + organizations: [], + user_created_timestamp: 1746773527283, + }, + email: 'test@gmail.com', + } + + const result = AuthApi.getUserAuthResponse(mockAuthToken) + + expect(result.organizations).toEqual([]) + }) + }) + + describe('logIn', () => { + const mockToken = 'valid.jwt.token' + const mockDecodedToken: AuthToken = { + exp: 1770396901, + iat: 1770395101, + jti: '1b921158-2396-461f-8b93-cee772503c2e', + iss: 'http://localhost:8084/realms/cvmanager', + aud: 'account', + sub: 'fc3d8729-8526-4aaa-805b-d64bf3b93860', + typ: 'Bearer', + azp: 'cvmanager-gui', + sid: '75f26b63-1df5-4b9a-953b-61fd72bd8c6b', + acr: '1', + 'allowed-origins': ['http://localhost:3000'], + realm_access: { roles: ['offline_access'] }, + resource_access: { account: { roles: ['manage-account'] } }, + scope: 'openid email profile', + email_verified: false, + name: 'Test User', + preferred_username: 'test@gmail.com', + given_name: 'Test', + family_name: 'User', + cvmanager_data: { + super_user: '1', + organizations: [{ org: 'Test Org', role: 'admin' }], + user_created_timestamp: 1746773527283, + }, + email: 'test@gmail.com', + } + + it('should successfully log in with valid token', async () => { + ;(jwtDecode as any).mockReturnValue(mockDecodedToken) + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + const result = await AuthApi.logIn(mockToken) + + expect(jwtDecode).toHaveBeenCalledWith(mockToken) + expect(global.fetch).toHaveBeenCalledWith( + `${EnvironmentVars.KEYCLOAK_HOST_URL}/realms/${EnvironmentVars.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${mockToken}`, + }, + } + ) + expect(result).toEqual({ + token: mockToken, + data: { + email: 'test@gmail.com', + first_name: 'Test', + last_name: 'User', + name: 'Test User', + super_user: true, + organizations: [{ organization: 'Test Org', role: 'ADMIN' }], + }, + expires_at: 1770396901000, + }) + }) + + it('should throw error when token verification fails', async () => { + ;(jwtDecode as any).mockReturnValue(mockDecodedToken) + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 }) + + await expect(AuthApi.logIn(mockToken)).rejects.toThrow('Token validation failed') + + expect(jwtDecode).toHaveBeenCalledWith(mockToken) + expect(global.fetch).toHaveBeenCalledWith( + `${EnvironmentVars.KEYCLOAK_HOST_URL}/realms/${EnvironmentVars.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${mockToken}`, + }, + } + ) + }) + + it('should throw error when token cannot be decoded', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + ;(jwtDecode as any).mockImplementation(() => { + throw new Error('Invalid token') + }) + + await expect(AuthApi.logIn(mockToken)).rejects.toThrow('Invalid JWT token') + + expect(jwtDecode).toHaveBeenCalledWith(mockToken) + expect(global.fetch).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + it('should throw error when Keycloak request fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + ;(jwtDecode as any).mockReturnValue(mockDecodedToken) + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await expect(AuthApi.logIn(mockToken)).rejects.toThrow('Token validation failed') + + expect(consoleErrorSpy).toHaveBeenCalledWith('Token verification failed:', expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + + it('should handle multiple organizations', async () => { + const mockTokenMultiOrg = { + ...mockDecodedToken, + cvmanager_data: { + super_user: '0', + organizations: [ + { org: 'Org 1', role: 'admin' }, + { org: 'Org 2', role: 'user' }, + { org: 'Org 3', role: 'operator' }, + ], + user_created_timestamp: 1746773527283, + }, + } + + ;(jwtDecode as any).mockReturnValue(mockTokenMultiOrg) + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + const result = await AuthApi.logIn(mockToken) + + expect(result.data.organizations).toEqual([ + { organization: 'Org 1', role: 'ADMIN' }, + { organization: 'Org 2', role: 'USER' }, + { organization: 'Org 3', role: 'OPERATOR' }, + ]) + expect(result.data.super_user).toBe(false) + }) }) }) diff --git a/webapp/src/apis/auth-api.ts b/webapp/src/apis/auth-api.ts index 543ffd36a..9d8c36d49 100644 --- a/webapp/src/apis/auth-api.ts +++ b/webapp/src/apis/auth-api.ts @@ -1,23 +1,81 @@ +import { jwtDecode } from 'jwt-decode' +import { AuthToken } from '../models/AuthToken' import EnvironmentVars from '../EnvironmentVars' class AuthApi { async logIn(token: string) { - const content = await fetch(EnvironmentVars.authEndpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: token, - }, - }) - - let json: UserAuthResponse | null = null - if (content.status === 200) { - json = (await content.json()) as UserAuthResponse + // Decode JWT token to get user info + const decodedToken = this.parseToken(token) + + // Verify token with Keycloak userinfo endpoint + const isValid = await this.verifyToken(token) + if (!isValid) { + throw new Error('Token validation failed') + } + + // Extract user info from decoded token and construct UserAuthResponse + const userAuthResponse = this.getUserAuthResponse(decodedToken) + + return { + token, + expires_at: decodedToken.exp * 1000, // Convert to milliseconds + data: userAuthResponse, + } + } + + async verifyToken(token: string): Promise { + try { + // Call Keycloak's userinfo endpoint to validate the token + // This endpoint requires a valid access token and returns user info if valid + const response = await fetch( + `${EnvironmentVars.KEYCLOAK_HOST_URL}/realms/${EnvironmentVars.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + + // Token is valid if we get a 200 response + return response.ok + } catch (error) { + console.error('Token verification failed:', error) + return false + } + } + + parseToken(token: string): AuthToken { + try { + return jwtDecode(token) + } catch (error) { + console.error('Failed to decode JWT token:', error) + throw new Error('Invalid JWT token') } + } + + parseRole(role: string): UserRole { + switch (role.toUpperCase()) { + case 'ADMIN': + return 'ADMIN' + case 'OPERATOR': + return 'OPERATOR' + default: + return 'USER' + } + } + getUserAuthResponse(token: AuthToken): UserAuthResponse { return { - json: json, - status: content.status, + email: token.email, + first_name: token.given_name, + last_name: token.family_name, + name: `${token.given_name} ${token.family_name}`, + super_user: token.cvmanager_data.super_user === '1', + organizations: token.cvmanager_data.organizations.map((org) => ({ + organization: org.org, + role: this.parseRole(org.role), + })), } } } diff --git a/webapp/src/apis/intersections/api-helper-cviz.ts b/webapp/src/apis/intersections/api-helper-cviz.ts index ab1456122..1008d80c8 100644 --- a/webapp/src/apis/intersections/api-helper-cviz.ts +++ b/webapp/src/apis/intersections/api-helper-cviz.ts @@ -26,6 +26,7 @@ class CvizApiHelper { abortController, responseType = 'json', booleanResponse = false, + returnErrorBody = false, toastOnFailure = true, toastOnSuccess = false, successMessage = 'Successfully completed request!', @@ -37,12 +38,13 @@ class CvizApiHelper { method?: string headers?: Record queryParams?: Record - body?: object + body?: object | string token?: string timeout?: number abortController?: AbortController responseType?: string booleanResponse?: boolean + returnErrorBody?: boolean toastOnFailure?: boolean toastOnSuccess?: boolean successMessage?: string @@ -75,7 +77,7 @@ class CvizApiHelper { headers: localHeaders, body: body ? localHeaders['Content-Type'] === 'application/x-www-form-urlencoded' - ? (body as string) + ? body?.toString() : JSON.stringify(body) : undefined, mode: 'cors', @@ -97,6 +99,13 @@ class CvizApiHelper { } else if (toastOnFailure) toast.error(failureMessage + ', with status code ' + response.status) if (booleanResponse) return false + if (returnErrorBody) { + const errorStatus = response.status + return response + .json() + .catch(() => null) + .then((body: any) => ({ __isErrorResponse: true, status: errorStatus, body })) + } return undefined } if (responseType === 'blob') { diff --git a/webapp/src/apis/intersections/notification-api.ts b/webapp/src/apis/intersections/notification-api.ts index e3e764686..459eafc8d 100644 --- a/webapp/src/apis/intersections/notification-api.ts +++ b/webapp/src/apis/intersections/notification-api.ts @@ -63,7 +63,7 @@ class NotificationApi { method: 'DELETE', abortController, token: token, - body: id.toString(), + queryParams: { key: id.toString() }, booleanResponse: true, tag: 'intersection', })) @@ -73,7 +73,7 @@ class NotificationApi { } else { toast.error(`Failed to Dismiss some Notifications`) } - return true + return success } async getAllNotifications({ diff --git a/webapp/src/apis/intersections/rsu-firmware-api.ts b/webapp/src/apis/intersections/rsu-firmware-api.ts new file mode 100644 index 000000000..e6691a3a6 --- /dev/null +++ b/webapp/src/apis/intersections/rsu-firmware-api.ts @@ -0,0 +1,41 @@ +import { ApiMsgRespWithCodes, RsuUpgradeCheckPostBody, RsuUpgradePostBody } from '../../models/RsuApi' +import { authApiHelper } from './api-helper-cviz' + +class RsuFirmwareApi { + async postRsuUpgradeData( + token: string, + body: RsuUpgradePostBody | RsuUpgradeCheckPostBody, + url_ext = '' + ): Promise | null> { + const response = await authApiHelper.invokeApi({ + path: `/devices/rsus/upgrade${url_ext}`, + method: 'POST', + token, + body, + tag: 'rsu', + toastOnFailure: false, + returnErrorBody: true, + failureMessage: 'Failed to submit RSU firmware upgrade request', + }) + + if (!response) { + return null + } + + if (response.__isErrorResponse) { + return { + body: response.body, + status: response.status, + message: response.body?.detail ?? `Request failed with status ${response.status}`, + } + } + + return { + body: response, + status: 200, + message: response?.message, + } + } +} + +export default new RsuFirmwareApi() diff --git a/webapp/src/apis/rsu-api.test.ts b/webapp/src/apis/rsu-api.test.ts index f5a301776..5447cd0b9 100644 --- a/webapp/src/apis/rsu-api.test.ts +++ b/webapp/src/apis/rsu-api.test.ts @@ -1,26 +1,18 @@ import RsuApi from './rsu-api' import EnvironmentVars from '../EnvironmentVars' +import { combineUrlPaths } from './intersections/api-helper-cviz' beforeEach(() => { fetchMock.mockClear() fetchMock.doMock() - EnvironmentVars.rsuInfoEndpoint = 'REACT_APP_ENV/rsuinfo' - EnvironmentVars.rsuOnlineEndpoint = 'REACT_APP_ENV/rsu-online-status' - EnvironmentVars.rsuCountsEndpoint = 'REACT_APP_ENV/rsucounts' - EnvironmentVars.rsuCommandEndpoint = 'REACT_APP_ENV/rsu-command' - EnvironmentVars.wzdxEndpoint = 'REACT_APP_ENV/wzdx-feed' - EnvironmentVars.geoMsgDataEndpoint = 'REACT_APP_ENV/rsu-geo-data' - EnvironmentVars.issScmsStatusEndpoint = 'REACT_APP_ENV/iss-scms-status' - EnvironmentVars.ssmSrmEndpoint = 'REACT_APP_ENV/rsu-ssm-srm-data' - EnvironmentVars.authEndpoint = 'REACT_APP_ENV/user-auth' - EnvironmentVars.adminAddRsu = 'REACT_APP_ENV/admin-new-rsu' - EnvironmentVars.adminRsu = 'REACT_APP_ENV/admin-rsu' - EnvironmentVars.adminAddIntersection = 'REACT_APP_ENV/admin-new-intersection' - EnvironmentVars.adminIntersection = 'REACT_APP_ENV/admin-intersection' - EnvironmentVars.adminAddUser = 'REACT_APP_ENV/admin-new-user' - EnvironmentVars.adminUser = 'REACT_APP_ENV/admin-user' - EnvironmentVars.adminAddOrg = 'REACT_APP_ENV/admin-new-org' - EnvironmentVars.adminOrg = 'REACT_APP_ENV/admin-org' + EnvironmentVars.rsuOnlineEndpoint = 'VITE_ENV/rsu-online-status' + EnvironmentVars.rsuCountsEndpoint = 'VITE_ENV/rsucounts' + EnvironmentVars.rsuCommandEndpoint = 'VITE_ENV/rsu-command' + EnvironmentVars.wzdxEndpoint = 'VITE_ENV/wzdx-feed' + EnvironmentVars.geoMsgDataEndpoint = 'VITE_ENV/rsu-geo-data' + EnvironmentVars.ssmSrmEndpoint = 'VITE_ENV/rsu-ssm-srm-data' + EnvironmentVars.adminAddOrg = 'VITE_ENV/admin-new-org' + EnvironmentVars.adminOrg = 'VITE_ENV/admin-org' }) it('Test apiHelper mock', async () => { @@ -29,9 +21,14 @@ it('Test apiHelper mock', async () => { const actualResponse = await RsuApi.getRsuInfo('testToken', 'testOrg') expect(actualResponse).toEqual(expectedResponse) - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.rsuInfoEndpoint) + expect(fetchMock.mock.calls[0][0]).toBe( + combineUrlPaths(EnvironmentVars.CVIZ_API_SERVER_URL!, EnvironmentVars.rsuInfoPath) + ) expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) + expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ + Authorization: 'Bearer testToken', + Organization: 'testOrg', + }) }) it('Test getRsuInfo', async () => { @@ -40,9 +37,14 @@ it('Test getRsuInfo', async () => { const actualResponse = await RsuApi.getRsuInfo('testToken', 'testOrg') expect(actualResponse).toEqual(expectedResponse) - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.rsuInfoEndpoint) + expect(fetchMock.mock.calls[0][0]).toBe( + combineUrlPaths(EnvironmentVars.CVIZ_API_SERVER_URL!, EnvironmentVars.rsuInfoPath) + ) expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) + expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ + Authorization: 'Bearer testToken', + Organization: 'testOrg', + }) }) it('Test getRsuInfo With Params', async () => { @@ -55,9 +57,14 @@ it('Test getRsuInfo With Params', async () => { const actualResponse = await RsuApi.getRsuInfo('testToken', 'testOrg', url_ext, query_params) expect(actualResponse).toEqual(expectedResponse) - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.rsuInfoEndpoint + url_ext + '?query_param=test') + expect(fetchMock.mock.calls[0][0]).toBe( + `${combineUrlPaths(EnvironmentVars.CVIZ_API_SERVER_URL!, EnvironmentVars.rsuInfoPath + url_ext)}?query_param=test` + ) expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) + expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ + Authorization: 'Bearer testToken', + Organization: 'testOrg', + }) }) it('Test getRsuOnline', async () => { @@ -112,32 +119,6 @@ it('Test getRsuCounts With Params', async () => { expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) }) -it('Test getRsuAuth', async () => { - const expectedResponse = { data: 'Test JSON' } - fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) - const actualResponse = await RsuApi.getRsuAuth('testToken', 'testOrg') - expect(actualResponse).toEqual(expectedResponse) - - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.authEndpoint) - expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) -}) - -it('Test getRsuAuth With Params', async () => { - // Set url_ext and query_params - const url_ext = 'url_ext' - const query_params = { query_param: 'test' } - - const expectedResponse = { data: 'Test JSON' } - fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) - const actualResponse = await RsuApi.getRsuAuth('testToken', 'testOrg', url_ext, query_params) - expect(actualResponse).toEqual(expectedResponse) - - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.authEndpoint + url_ext + '?query_param=test') - expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) -}) - it('Test getRsuCommand', async () => { const expectedResponse = { data: 'Test JSON' } fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) @@ -190,32 +171,6 @@ it('Test getSsmSrmData With Params', async () => { expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken' }) }) -it('Test getIssScmsStatus', async () => { - const expectedResponse = { data: 'Test JSON' } - fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) - const actualResponse = await RsuApi.getIssScmsStatus('testToken', 'testOrg') - expect(actualResponse).toEqual(expectedResponse) - - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.issScmsStatusEndpoint) - expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) -}) - -it('Test getIssScmsStatus With Params', async () => { - // Set url_ext and query_params - const url_ext = 'url_ext' - const query_params = { query_param: 'test' } - - const expectedResponse = { data: 'Test JSON' } - fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) - const actualResponse = await RsuApi.getIssScmsStatus('testToken', 'testOrg', url_ext, query_params) - expect(actualResponse).toEqual(expectedResponse) - - expect(fetchMock.mock.calls[0][0]).toBe(EnvironmentVars.issScmsStatusEndpoint + url_ext + '?query_param=test') - expect(fetchMock.mock.calls[0][1].method).toBe('GET') - expect(fetchMock.mock.calls[0][1].headers).toStrictEqual({ Authorization: 'testToken', Organization: 'testOrg' }) -}) - it('Test getWzdxData', async () => { const expectedResponse = { data: 'Test JSON' } fetchMock.mockResponseOnce(JSON.stringify(expectedResponse)) diff --git a/webapp/src/apis/rsu-api.ts b/webapp/src/apis/rsu-api.ts index 585bf606f..cbeb4f491 100644 --- a/webapp/src/apis/rsu-api.ts +++ b/webapp/src/apis/rsu-api.ts @@ -1,12 +1,10 @@ import EnvironmentVars from '../EnvironmentVars' import { WZDxWorkZoneFeed } from '../models/wzdx/WzdxWorkZoneFeed42' -import { MooveAiFeature } from '../models/moove-ai/MooveAiData' import apiHelper from './api-helper' +import { authApiHelper } from './intersections/api-helper-cviz' import { ApiMsgRespWithCodes, GetRsuCommandResp, - GetRsuUserAuthResp, - IssScmsStatus, RsuCommandPostBody, RsuCounts, RsuInfoList, @@ -23,14 +21,18 @@ class RsuApi { org: string, url_ext = '', query_params: Record = {} - ): Promise => - apiHelper._getData({ - url: EnvironmentVars.rsuInfoEndpoint + url_ext, + ): Promise => { + const response = await authApiHelper.invokeApi({ + path: `${EnvironmentVars.rsuInfoPath}${url_ext}`, + queryParams: query_params, token, - query_params, - additional_headers: { Organization: org }, + headers: { Organization: org }, + toastOnFailure: false, tag: 'rsu', }) + + return (response ?? null) as RsuInfoList + } getRsuOnline = async ( token: string, org: string, @@ -57,7 +59,7 @@ class RsuApi { additional_headers: { Organization: org }, tag: 'rsu', }) - getRsuMsgFwdConfigs = async ( + getCachedRsuMsgFwdConfigsFromDatabase = async ( token: string, org: string, url_ext = '', @@ -70,14 +72,14 @@ class RsuApi { additional_headers: { Organization: org }, tag: 'rsu', }) - getRsuAuth = async ( + getRsuMsgConfigsFromRsu = async ( token: string, org: string, url_ext = '', query_params: Record = {} - ): Promise => + ): Promise => apiHelper._getData({ - url: EnvironmentVars.authEndpoint + url_ext, + url: EnvironmentVars.rsuMsgFwdFetchEndpoint + url_ext, token, query_params, additional_headers: { Organization: org }, @@ -103,19 +105,6 @@ class RsuApi { query_params, tag: 'rsu', }) - getIssScmsStatus = async ( - token: string, - org: string, - url_ext = '', - query_params: Record = {} - ): Promise => - apiHelper._getData({ - url: EnvironmentVars.issScmsStatusEndpoint + url_ext, - token, - query_params, - additional_headers: { Organization: org }, - tag: 'rsu', - }) // WZDx getWzdxData = async (token: string, url_ext = '', query_params = {}): Promise => @@ -126,15 +115,6 @@ class RsuApi { tag: 'wzdx', }) - // Moove AI - postMooveAiData = async (token: string, body: string, url_ext = ''): Promise> => - apiHelper._postData({ - url: EnvironmentVars.mooveAiDataEndpoint + url_ext, - body, - token, - tag: 'mooveai', - }) - // POST postGeoMsgData = async (token: string, body: string, url_ext = ''): Promise> => apiHelper._postData({ url: EnvironmentVars.geoMsgDataEndpoint + url_ext, body, token, tag: 'rsu' }) @@ -165,23 +145,6 @@ class RsuApi { tag: 'rsu', }) } - - // POST - postContactSupport = async (json: object): Promise> => { - return await apiHelper._postData({ - url: EnvironmentVars.contactSupport, - body: JSON.stringify(json), - tag: 'rsu', - }) - } - - // POST - postRsuErrorSummary = async (json: object): Promise> => { - return await apiHelper._postData({ - url: EnvironmentVars.rsuErrorSummary, - body: JSON.stringify(json), - }) - } } const rsuApiObject = new RsuApi() diff --git a/webapp/src/components/AdminDeletionOptions.tsx b/webapp/src/components/AdminDeletionOptions.tsx index 70bec7f36..7cbf94ade 100644 --- a/webapp/src/components/AdminDeletionOptions.tsx +++ b/webapp/src/components/AdminDeletionOptions.tsx @@ -1,5 +1,3 @@ -import React from 'react' - export const Options = ( deleteTitle: string, deleteMessage: string, diff --git a/webapp/src/components/AdminFormManager.test.tsx b/webapp/src/components/AdminFormManager.test.tsx index 2b29863fe..416a96856 100644 --- a/webapp/src/components/AdminFormManager.test.tsx +++ b/webapp/src/components/AdminFormManager.test.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { render } from '@testing-library/react' import AdminFormManager from './AdminFormManager' import { replaceChaoticIds } from '../utils/test-utils' diff --git a/webapp/src/components/AdminFormManager.tsx b/webapp/src/components/AdminFormManager.tsx index 1596a7951..eff824704 100644 --- a/webapp/src/components/AdminFormManager.tsx +++ b/webapp/src/components/AdminFormManager.tsx @@ -1,4 +1,3 @@ -import React from 'react' import AdminRsuTab from '../features/adminRsuTab/AdminRsuTab' import AdminUserTab from '../features/adminUserTab/AdminUserTab' import AdminOrganizationTab from '../features/adminOrganizationTab/AdminOrganizationTab' diff --git a/webapp/src/components/AdminOrganizationDeleteMenu.test.tsx b/webapp/src/components/AdminOrganizationDeleteMenu.test.tsx index 3dfafc017..ce9623504 100644 --- a/webapp/src/components/AdminOrganizationDeleteMenu.test.tsx +++ b/webapp/src/components/AdminOrganizationDeleteMenu.test.tsx @@ -1,11 +1,11 @@ -import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import AdminOrganizationDeleteMenu from './AdminOrganizationDeleteMenu' import { replaceChaoticIds } from '../utils/test-utils' import { confirmAlert } from 'react-confirm-alert' import { ThemeProvider } from '@mui/material' import { testTheme } from '../styles' -jest.mock('react-confirm-alert') +import { vi } from 'vitest' +vi.mock('react-confirm-alert') it('should take a snapshot', () => { const { container } = render( diff --git a/webapp/src/components/AdminOrganizationDeleteMenu.tsx b/webapp/src/components/AdminOrganizationDeleteMenu.tsx index 2646dfcb0..4b357c4f4 100644 --- a/webapp/src/components/AdminOrganizationDeleteMenu.tsx +++ b/webapp/src/components/AdminOrganizationDeleteMenu.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { confirmAlert } from 'react-confirm-alert' import 'react-confirm-alert/src/react-confirm-alert.css' import { Options } from './AdminDeletionOptions' diff --git a/webapp/src/components/AdminTable.test.tsx b/webapp/src/components/AdminTable.test.tsx index 75177a2b1..1c5f68529 100644 --- a/webapp/src/components/AdminTable.test.tsx +++ b/webapp/src/components/AdminTable.test.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { render } from '@testing-library/react' import AdminTable from './AdminTable' import { replaceChaoticIds } from '../utils/test-utils' diff --git a/webapp/src/components/AdminTable.tsx b/webapp/src/components/AdminTable.tsx index aaa6ddcfc..18229e31a 100644 --- a/webapp/src/components/AdminTable.tsx +++ b/webapp/src/components/AdminTable.tsx @@ -1,5 +1,13 @@ import React from 'react' -import MaterialTable, { Action, Column, MTableAction, MTableCell, MTableToolbar } from '@material-table/core' +import MaterialTable, { + Action, + Column, + MTableAction, + MTableCell, + MTableToolbar, + Query, + QueryResult, +} from '@material-table/core' import { makeStyles } from '@mui/styles' import '../features/adminRsuTab/Admin.css' @@ -9,12 +17,16 @@ import { AddCircleOutline, DeleteOutline, ModeEditOutline, Refresh } from '@mui/ interface AdminTableProps { actions: Action[] columns: Column[] - data: any[] + data?: any[] // Optional for client-side pagination title: string editable?: any selection?: boolean tableLayout?: 'auto' | 'fixed' pageSizeOptions?: any + // Server-side pagination props + handleQueryChange?: (query: Query) => Promise> + isLoading?: boolean + tableRef?: React.MutableRefObject } const useStyles = makeStyles({ @@ -62,6 +74,7 @@ const getActionIcon = (title: string) => { const AdminTable = (props: AdminTableProps) => { const theme = useTheme() const classes = useStyles() + // Function to check if a row is missing organizations const isMissingOrganizations = (rowData: any) => { try { @@ -72,6 +85,9 @@ const AdminTable = (props: AdminTableProps) => { } } + // Determine if server-side pagination is enabled + const isServerSidePagination = props.handleQueryChange !== undefined + return ( { columns={props.columns?.map((column) => ({ ...column, }))} - data={props.data} + // Use data function for server-side, data array for client-side + data={isServerSidePagination ? props.handleQueryChange! : (props.data ?? [])} title={props.title} editable={props.editable} + isLoading={props.isLoading} + tableRef={props.tableRef} options={{ selection: props.selection === undefined ? true : props.selection, searchFieldAlignment: 'left', @@ -103,18 +122,21 @@ const AdminTable = (props: AdminTableProps) => { tableLayout: props.tableLayout === undefined ? 'fixed' : props.tableLayout, rowStyle: (rowData) => ({ overflowWrap: 'break-word', - border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, // Add cell borders - backgroundColor: isMissingOrganizations(rowData) ? theme.palette.custom.tableErrorBackground : 'inherit', // Highlight row if missing organizations + border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + backgroundColor: isMissingOrganizations(rowData) ? theme.palette.custom.tableErrorBackground : 'inherit', }), headerStyle: { backgroundColor: theme.palette.background.paper, }, - pageSize: 5, - pageSizeOptions: props.pageSizeOptions === undefined ? [5, 10, 20] : props.pageSizeOptions, + pageSize: 25, + pageSizeOptions: props.pageSizeOptions === undefined ? [5, 25, 50, 100] : props.pageSizeOptions, + paging: true, + search: true, // Enable search UI; search term is passed to handleQueryChange for server-side filtering + debounceInterval: 500, }} components={{ - Cell: (props) => { - const rowData = props.data + Cell: (cellProps) => { + const rowData = cellProps.data return ( { borderRadius: '4px !important', }, }} - {...props} + {...cellProps} /> ) }, - Action: (props: any) => { - const { action } = props + Action: (actionProps: any) => { + const { action } = actionProps const iconProps = action?.iconProps if (iconProps?.itemType === 'displayIcon') { @@ -175,14 +197,14 @@ const AdminTable = (props: AdminTableProps) => { }, }} > - + ) }, - Toolbar: (props) => { + Toolbar: (toolbarProps) => { return (
- +
) }, diff --git a/webapp/src/components/ContactSupportMenu.test.tsx b/webapp/src/components/ContactSupportMenu.test.tsx index 7b086ada3..da847af83 100644 --- a/webapp/src/components/ContactSupportMenu.test.tsx +++ b/webapp/src/components/ContactSupportMenu.test.tsx @@ -1,10 +1,22 @@ -import React from 'react' import { render } from '@testing-library/react' import ContactSupportMenu from './ContactSupportMenu' import { replaceChaoticIds } from '../utils/test-utils' +import { setupStore } from '../store' +import { Provider } from 'react-redux' +import { ThemeProvider } from '@mui/material' +import { testTheme } from '../styles' +import { BrowserRouter } from 'react-router-dom' it('should take a snapshot', () => { - const { container } = render() + const { container } = render( + + + + + + + + ) expect(replaceChaoticIds(container)).toMatchSnapshot() }) diff --git a/webapp/src/components/ContactSupportMenu.tsx b/webapp/src/components/ContactSupportMenu.tsx index fff7d6cf0..0aebce4de 100644 --- a/webapp/src/components/ContactSupportMenu.tsx +++ b/webapp/src/components/ContactSupportMenu.tsx @@ -1,9 +1,8 @@ -import React, { useState } from 'react' +import { useState } from 'react' import { Form } from 'react-bootstrap' import { useForm } from 'react-hook-form' import 'react-widgets/styles.css' -import RsuApi from '../apis/rsu-api' import './css/ContactSupportMenu.css' import toast from 'react-hot-toast' @@ -11,6 +10,7 @@ import Dialog from '@mui/material/Dialog' import { Button, DialogActions, DialogContent, DialogTitle } from '@mui/material' import { AdminButton } from '../styles/components/AdminButton' import '../styles/fonts/museo-slab.css' +import { useSendContactSupportEmailMutation } from '../features/api/emailApiSlice' const ContactSupportMenu = () => { const [hidden, setHidden] = useState(true) // hidden by default @@ -21,15 +21,16 @@ const ContactSupportMenu = () => { formState: { errors }, } = useForm() - const onSubmit = async (data: object) => { + const [submitSupportRequest] = useSendContactSupportEmailMutation() + + const onSubmit = async (data: SupportRequestEmailContents) => { try { - const res = await RsuApi.postContactSupport(data) - const status = res.status - if (status === 200) { - toast.success('Successfully sent email') + const response = await submitSupportRequest(data).unwrap() + if (response.failureCount === 0) { + toast.success(`Successfully sent support request`) reset() } else { - toast.error('Something went wrong: ' + status) + toast.error(`Failed to send support request`) } } catch (exception_var) { console.error('Error in ContactSupportMenu onSubmit', exception_var) diff --git a/webapp/src/components/HaasAlertVisualization.tsx b/webapp/src/components/HaasAlertVisualization.tsx index 39a0afb12..7f65723c2 100644 --- a/webapp/src/components/HaasAlertVisualization.tsx +++ b/webapp/src/components/HaasAlertVisualization.tsx @@ -82,9 +82,9 @@ export const HaasAlertVisualization: React.FC = ({ position: 'absolute', right: '10px', top: - menuSelection.includes('V2x Message Viewer') && menuSelection.includes('Configure RSUs') + menuSelection.includes('V2X Message Viewer') && menuSelection.includes('Configure RSUs') ? '540px' - : menuSelection.includes('V2x Message Viewer') + : menuSelection.includes('V2X Message Viewer') ? '380px' : menuSelection.includes('Configure RSUs') ? '180px' diff --git a/webapp/src/components/Header.test.tsx b/webapp/src/components/Header.test.tsx index 8a0d8183b..569dd3fd0 100644 --- a/webapp/src/components/Header.test.tsx +++ b/webapp/src/components/Header.test.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { render } from '@testing-library/react' import Header from './Header' import { Provider } from 'react-redux' @@ -9,24 +8,26 @@ import { useKeycloak } from '@react-keycloak/web' import { replaceChaoticIds } from '../utils/test-utils' import ContactSupportMenu from './ContactSupportMenu' -jest.mock('@react-keycloak/web') +import { vi } from 'vitest' + +vi.mock('@react-keycloak/web') const mockKeycloak = { authenticated: true, - login: jest.fn(), - logout: jest.fn(), - register: jest.fn(), - accountManagement: jest.fn(), - loadUserProfile: jest.fn(), + login: vi.fn(), + logout: vi.fn(), + register: vi.fn(), + accountManagement: vi.fn(), + loadUserProfile: vi.fn(), } describe('
', () => { beforeEach(() => { - ;(useKeycloak as any).mockReturnValue([mockKeycloak]) + ;(useKeycloak as any).mockReturnValue({ keycloak: mockKeycloak }) }) afterEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) it('should take a snapshot', () => { diff --git a/webapp/src/components/Header.tsx b/webapp/src/components/Header.tsx index 44297fdeb..797e05ac6 100644 --- a/webapp/src/components/Header.tsx +++ b/webapp/src/components/Header.tsx @@ -140,14 +140,14 @@ const Header = () => { > {(authLoginData?.data?.organizations ?? []).map((permission) => ( } - value={permission.name} + value={permission.organization} sx={{ '& .MuiTypography-root': { color: - permission.name === organizationName + permission.organization === organizationName ? theme.palette.text.primary : theme.palette.text.secondary, fontFamily: 'Trebuchet MS, Arial, Helvetica, sans-serif', diff --git a/webapp/src/components/Help.test.tsx b/webapp/src/components/Help.test.tsx index 1f67244e2..e314d2281 100644 --- a/webapp/src/components/Help.test.tsx +++ b/webapp/src/components/Help.test.tsx @@ -1,10 +1,32 @@ -import React from 'react' import { render } from '@testing-library/react' import Help from './Help' import { replaceChaoticIds } from '../utils/test-utils' +import { MemoryRouter } from 'react-router-dom' +import { Provider } from 'react-redux' +import { ThemeProvider } from '@mui/material' +import { testTheme } from '../styles' +import { setupStore } from '../store' it('should take a snapshot', () => { - const { container } = render() + jest.mock('../EnvironmentVars', () => ({ + EnvironmentVars: { + ENABLE_RSU_FEATURES: true, + ENABLE_WZDX_FEATURES: true, + ENABLE_HAAS_FEATURES: true, + }, + })) + + const { container } = render( + + + + + + + + ) expect(replaceChaoticIds(container)).toMatchSnapshot() }) diff --git a/webapp/src/components/Help.tsx b/webapp/src/components/Help.tsx index c87d9d1ed..6ae702446 100644 --- a/webapp/src/components/Help.tsx +++ b/webapp/src/components/Help.tsx @@ -1,67 +1,279 @@ -import React from 'react' import '../components/css/Help.css' -import popup from '../icons/rsu_popup.PNG' -import status from '../icons/rsu_status.PNG' -import table from '../icons/rsu_count.PNG' -import menu from '../icons/rsu_menu.PNG' -import heatmap from '../icons/rsu_heatmap.PNG' -import configure from '../icons/rsu_configure.PNG' +import popup from '../icons/help/rsu_popup_and_config_menu.png' +import organizationSelection from '../icons/help/organization_selection.png' +import mapOverview from '../icons/help/map_overview.png' +import statusMenu from '../icons/help/rsu_status_menu.png' +import countMenu from '../icons/help/rsu_count_menu.png' +import table from '../icons/help/rsu_count.png' import EnvironmentVars from '../EnvironmentVars' import ContactSupportMenu from './ContactSupportMenu' import { BorderedImage } from '../styles/components/BorderedImage' -import { Stack, Container, useTheme } from '@mui/material' +import { + Stack, + Container, + useTheme, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + Paper, + TableBody, +} from '@mui/material' +import { Link } from 'react-router-dom' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' const Help = () => { const theme = useTheme() + + const permissions = [ + { action: 'View Map Data (including RSUs)', user: true, operator: true, admin: true }, + { action: 'Register, Manage, and Configure RSUs', user: false, operator: true, admin: true }, + { action: 'Manage Intersection Settings and Notifications', user: false, operator: true, admin: true }, + { action: 'Manage Users', user: false, operator: false, admin: true }, + { action: 'Add and Remove Resources Within Organizations', user: false, operator: false, admin: true }, + ] + + const permissionsTable = ( + + + + + + User Roles and Permissions (By Organization) + + + + + {/* Empty cell for action column */} + + + User + + + Operator + + + Admin + + + + + {permissions.map((row, index) => ( + + {row.action} + + {row.user ? ( + + ) : ( + + )} + + + {row.operator ? ( + + ) : ( + + )} + + + {row.admin ? ( + + ) : ( + + )} + + + ))} + +
+
+ ) + return ( -

Welcome to the {EnvironmentVars.DOT_NAME} CV Manager Website

+

{`Welcome to the ${EnvironmentVars.DOT_NAME} CV Manager`}

+

+ This application helps organizations manage and monitor deployed RSUs, monitor and detect issues with + connected intersections, and view numerous data types all in one application. +

+ +

Organizations and Profiles

+

+ This application uses organizations to manage permissions. All devices and users within the CV Manager are + associated with one or more organizations. When using the CV Manager, you can only view one organization at a + time. At the top right corner, you will see the User Profile menu. This menu allows the user to change + organizations, if they are a member of multiple, as well as logging out of the application. +

+ + + +

Permissions

+

- This application shows the physical location and message counts for each RSU installed by the Colorado - Department of Transportation at various road sites throughout Colorado. + There are 3 roles within the CV Manager: User, Operator, and Admin. Some users can also be granted Super User + permissions, which enables access to all resources within an organization and increases their ability to move + resources between organizations.

+ + {permissionsTable} + +

Enabled Features

- The map on this website will represent the location of each RSU with a red, green, or yellow dot (shown - below). A green dot represents an RSU that is online. A yellow dot represents an RSU that is offline but was - recently active. A red dot represents an RSU that is not currently online. + The CV Manager can have different feature sets enabled or disabled. In this application, the following + features are currently enabled or disabled as shown below:

- +
    +
  • {`RSU Monitoring and Configuration: ${EnvironmentVars.ENABLE_RSU_FEATURES ? 'ENABLED' : 'DISABLED'}`}
  • +
  • {`Intersection Map/Dashboard: ${EnvironmentVars.ENABLE_INTERSECTION_FEATURES ? 'ENABLED' : 'DISABLED'}`}
  • +
  • {`WZDx Viewer: ${EnvironmentVars.ENABLE_WZDX_FEATURES ? 'ENABLED' : 'DISABLED'}`}
  • +
  • {`HAAS Alert Viewer: ${EnvironmentVars.ENABLE_HAAS_FEATURES ? 'ENABLED' : 'DISABLED'}`}
  • +
+ +

+ Map Dashboard +

+

- Clicking on the RSU will give the following information: IP address, online status, time last online, milepost - number, serial number, and number of message counts (shown below). + The map dashboard is composed of a Mapbox map (background), the + Map Layer Menu (red), and + RSU Status and Message Counts Menu (green).

- + + + +

Map Layers

+

- The menu on the right side of the webpage will allow users to select the time range and message type to be - displayed for the RSUs. The menu will toggle between being hidden and visible each time the red X is clicked. - The time frame can be changed by adjusting the date and time values in the date-time pickers. The top - date-time picker represents the start time while the bottom represents the end time. Below the date-time - pickers is a dropdown menu where the RSU message type can be selected. Each time a value is updated in either - the date-time pickers or message selection dropdown the webpage will update accordingly. These elements are - shown in the image below: + The menu on the left contains three sections: Map Layers, RSU Filters, and + RSU Configuration. The Map Layers section allows users to visualize various data types. + Available layers include:

- + +
    +
  • + RSU Viewer: Displays RSU locations and status. Selecting one opens a popup and side panel + for configuration. Colors: +
      +
    • Green = online and reporting
    • +
    • Yellow = recently offline
    • +
    • Red = offline for extended time
    • +
    +
  • +
  • + Heatmap: Displays a message-count heatmap +
  • +
  • + V2X Message Viewer: Query messages for a map region +
  • +
  • + WZDx Viewer: Displays work zone events +
  • +
  • + Intersections: Shows connected intersections and IDs +
  • +
  • + HAAS Alert Viewer: Query alert incidents by time +
  • +
+

- The table included in the menu will show the number of message counts for each RSU in the given time frame - regardless of online status (shown below). The table can be sorted by RSU, road, or count by clicking on the - desired column header. Each time a column header is clicked, it will toggle between sorting the data in an - ascending/descending fashion. + The RSU Filters section allows filtering RSUs by vendor and status. The RSU Configuration section allows + Operators/Admins to select RSUs on the map and configure them in bulk.

- + +

Configuring RSUs (Requires Operator or Admin)

+

- Selecting 'Heat Map' on the navigation bar will show a heat map representing RSU message counts for the time - range and message type selected in the menu (shown below). Any update to time range or message type in the - menu will carry over between the heat map and RSU map. + Selecting an RSU opens a popup and configuration menu. It displays IP address, online status, last report + time, milepost, serial number, and message count. Depending on access, users can retrieve configurations, + modify them, check/apply firmware updates, or reboot the RSU.

- + + + +
Message Forwarding Current Configuration
+

Message forwarding rules come in three types:

+ +
    +
  • TX (transmitted messages: TIM, MAP, SPAT, SSM)
  • +
  • RX (received messages: BSM, SRM, SDSM)
  • +
  • Generic (SNMP 4.1: applies to all messages)
  • +
+ +

Each rule includes:

+ +
    +
  • Message Type
  • +
  • Destination IP
  • +
  • Port
  • +
  • Start / End date
  • +
  • Security header enabled
  • +
  • Active state
  • +
  • Delete button
  • +
+ +
Message Forwarding Management
+

Create forwarding rules by entering:

+
    +
  • Destination IP
  • +
  • Message type
  • +
  • Security header
  • +
+ +
Firmware Management
+

Check for firmware updates and apply if available.

+ +
Reboot
+

Reboot the selected RSU.

+ +

RSU Status and Message Counts

+ +

+ On the right side are two menus: RSU Status Menu and + Message Count Menu. +

+ +

+ The RSU Status Menu lists all RSUs and their status. Users can print a full or error-only report. For each + device you will find: +

+ +
    +
  • Location
  • +
  • Online status (green / red / yellow)
  • +
  • SCMS certificate status
  • +
  • RSU IP address
  • +
+ + + +

+ The Message Count Menu filters RSU message counts by time range and message type. Changing any filter updates + the map and table automatically. +

+ + + +

A table below shows the number of messages from each RSU, sortable by RSU name, road, or message count.

+ + +

- Selecting 'Configure' on the navigation bar will allow users to perform certain actions to the RSU based on - their role. An RSU must first be selected on the RSU Map before any options become available. Users will be - able to perform one or more of the following: pull current message forwarding configuration, add/delete - message forwarding configurations, and perform an RSU reboot. + The RSU Configuration section allows applying configuration changes to multiple RSUs based on a selected + geographic region.

-
diff --git a/webapp/src/components/MooveAiHardBrakingLegend.test.tsx b/webapp/src/components/MooveAiHardBrakingLegend.test.tsx deleted file mode 100644 index d66d0fec1..000000000 --- a/webapp/src/components/MooveAiHardBrakingLegend.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' -import MooveAiHardBrakingLegend from './MooveAiHardBrakingLegend' -import { replaceChaoticIds } from '../utils/test-utils' - -it('should take a snapshot', () => { - const { container } = render() - - expect(replaceChaoticIds(container)).toMatchSnapshot() -}) diff --git a/webapp/src/components/MooveAiHardBrakingLegend.tsx b/webapp/src/components/MooveAiHardBrakingLegend.tsx deleted file mode 100644 index 9aae59f10..000000000 --- a/webapp/src/components/MooveAiHardBrakingLegend.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react' -import { Typography } from '@mui/material' - -const MooveAiHardBrakingLegend = () => { - return ( -
- - Hard Braking Color Guide - -
-
-
- 0-249 Hard Brakes -
-
-
- 250-499 Hard Brakes -
-
-
- 500-749 Hard Brakes -
-
-
- 750+ Hard Brakes -
-
-
- - Determined by a weekly average - -
- ) -} - -const gridStyle: React.CSSProperties = { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - marginTop: '10px', - marginLeft: '10px', -} - -const gridRowStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - marginBottom: '5px', -} - -const gridBottomStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - marginBottom: '10px', -} - -export default MooveAiHardBrakingLegend diff --git a/webapp/src/components/RsuErrorSummary.test.tsx b/webapp/src/components/RsuErrorSummary.test.tsx deleted file mode 100644 index a0662f3d2..000000000 --- a/webapp/src/components/RsuErrorSummary.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' -import RsuErrorSummary from './RsuErrorSummary' -import { replaceChaoticIds } from '../utils/test-utils' - -it('should take a snapshot', () => { - const { container } = render( -
} defaultTabIndex={0} tabs={mockTabs} />} + /> + + + + + ) +} + +describe('VerticalTabs getSelectedTab functionality', () => { + it('should select the correct tab when the path matches exactly', () => { + renderVerticalTabs(['/admin/rsus']) + + const rsuTab = screen.getByRole('tab', { name: /RSUs/i }) + expect(rsuTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('RSU Page Content')).toBeInTheDocument() + }) + + it('should select the correct tab when navigating to a sub-route (e.g., edit page)', () => { + renderVerticalTabs(['/admin/rsus/editRsu/10.0.0.180']) + + const rsuTab = screen.getByRole('tab', { name: /RSUs/i }) + expect(rsuTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('RSU Page Content')).toBeInTheDocument() + }) + + it('should select the intersections tab when on an intersections sub-route', () => { + renderVerticalTabs(['/admin/intersections/some-sub-route']) + + const intersectionsTab = screen.getByRole('tab', { name: /Intersections/i }) + expect(intersectionsTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('Intersections Page Content')).toBeInTheDocument() + }) + + it('should fallback to the default tab if no matches are found in the path', () => { + renderVerticalTabs(['/admin/unknown-tab']) + + // With defaultTabIndex={0}, it should default to the first tab (RSUs) + const rsuTab = screen.getByRole('tab', { name: /RSUs/i }) + expect(rsuTab).toHaveAttribute('aria-selected', 'true') + // But the content should be "Not Found" because the internal Routes won't match + expect(screen.getByText('Not Found')).toBeInTheDocument() + }) + + it('should handle deep sub-routes correctly', () => { + renderVerticalTabs(['/admin/organizations/edit/123/users/456']) + + const orgTab = screen.getByRole('tab', { name: /Organizations/i }) + expect(orgTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('Organizations Page Content')).toBeInTheDocument() + }) + + it('should correctly switch tabs when path changes', () => { + render( + + + + + Not Found
} defaultTabIndex={0} tabs={mockTabs} />} + /> + + + + + ) + + expect(screen.getByRole('tab', { name: /RSUs/i })).toHaveAttribute('aria-selected', 'true') + + // Note: To test actual navigation/rerender with MemoryRouter is tricky without wrapping everything. + // In our case, VerticalTabs uses useEffect on location.pathname, so it should update. + // But since MemoryRouter's initialEntries is immutable for a given render, we'd need to simulate navigation. + }) +}) diff --git a/webapp/src/components/VerticalTabs.tsx b/webapp/src/components/VerticalTabs.tsx index cefd61527..f47b7d815 100644 --- a/webapp/src/components/VerticalTabs.tsx +++ b/webapp/src/components/VerticalTabs.tsx @@ -1,11 +1,6 @@ import React, { useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' -import { updateTableData as updateRsuTableData } from '../features/adminRsuTab/adminRsuTabSlice' -import { getAvailableUsers } from '../features/adminUserTab/adminUserTabSlice' import '../features/adminRsuTab/Admin.css' -import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' -import { RootState } from '../store' import { alpha, Box, Tab, Tabs, useTheme } from '@mui/material' import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { evaluateFeatureFlags } from '../feature-flags' @@ -59,7 +54,6 @@ interface VerticalTabProps { function VerticalTabs(props: VerticalTabProps) { const { notFoundRoute, defaultTabIndex, tabs } = props - const dispatch: ThunkDispatch = useDispatch() const theme = useTheme() const location = useLocation() const filteredTabs = tabs.filter((tab) => evaluateFeatureFlags(tab.tag)) @@ -88,7 +82,27 @@ function VerticalTabs(props: VerticalTabProps) { } } - const getSelectedTab = () => location.pathname.split('/').at(-1) || defaultTabKey + /** + * Retrieves the currently selected tab key based on the URL pathname. + * + * The function splits the current `location.pathname` into parts and + * iterates through them to find a match in the `filteredTabs` array. + * The first matching path in `filteredTabs` is returned as the selected tab key. + * If no match is found, the function defaults to returning `defaultTabKey`. + * + * @function + * @returns {string} The key of the selected tab or the default tab key if no match is found. + */ + const getSelectedTab = () => { + const pathParts = location.pathname.split('/') + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i] + if (filteredTabs.some((tab) => tab.path === part)) { + return part + } + } + return defaultTabKey + } const [value, setValue] = useState(getSelectedTab()) @@ -100,11 +114,6 @@ function VerticalTabs(props: VerticalTabProps) { setValue(newValue) } - useEffect(() => { - dispatch(updateRsuTableData()) - dispatch(getAvailableUsers()) - }, [dispatch]) - return ( @@ -6,7 +6,7 @@ exports[`snapshot organization 1`] = ` class="scroll-div" >
- -
+ +
- + +
- + +
@@ -604,7 +652,7 @@ exports[`snapshot rsu 1`] = `
+ +
+ + TIM Deposit + + + +
+ + +
+ + SNMP Monitoring + + + +
+ No records to display @@ -891,14 +1023,14 @@ exports[`snapshot rsu 1`] = `
- 5 rows + 25 rows
0-0 of 0 @@ -1032,6 +1164,37 @@ exports[`snapshot rsu 1`] = ` +
+
+
+ + + + + +
+
+
@@ -1094,7 +1257,7 @@ exports[`snapshot user 1`] = ` @@ -1248,10 +1411,10 @@ exports[`snapshot user 1`] = `

Rows per page:

@@ -1481,26 +1644,26 @@ exports[`snapshot user 1`] = ` class="MuiInputBase-root MuiInputBase-colorPrimary MuiTablePagination-input css-mocked-MuiInputBase-root-MuiTablePagination-select" > 0-0 of 0 @@ -1634,6 +1797,37 @@ exports[`snapshot user 1`] = ` +
+
+
+ + + + + +
+
+
diff --git a/webapp/src/components/__snapshots__/AdminOrganizationDeleteMenu.test.tsx.snap b/webapp/src/components/__snapshots__/AdminOrganizationDeleteMenu.test.tsx.snap index a020ff285..d152d067b 100644 --- a/webapp/src/components/__snapshots__/AdminOrganizationDeleteMenu.test.tsx.snap +++ b/webapp/src/components/__snapshots__/AdminOrganizationDeleteMenu.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`should take a snapshot 1`] = `
diff --git a/webapp/src/components/__snapshots__/AdminTable.test.tsx.snap b/webapp/src/components/__snapshots__/AdminTable.test.tsx.snap index 6df8ef19d..539deb723 100644 --- a/webapp/src/components/__snapshots__/AdminTable.test.tsx.snap +++ b/webapp/src/components/__snapshots__/AdminTable.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`should take a snapshot 1`] = `
@@ -122,10 +122,10 @@ exports[`should take a snapshot 1`] = ` style="background-color: rgb(255, 255, 255); width: 48px;" > @@ -152,7 +152,7 @@ exports[`should take a snapshot 1`] = ` > - 5 rows + 25 rows
0-0 of 0 diff --git a/webapp/src/components/__snapshots__/ContactSupportMenu.test.tsx.snap b/webapp/src/components/__snapshots__/ContactSupportMenu.test.tsx.snap index 53adcc3a6..befe64cf2 100644 --- a/webapp/src/components/__snapshots__/ContactSupportMenu.test.tsx.snap +++ b/webapp/src/components/__snapshots__/ContactSupportMenu.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`should take a snapshot 1`] = `
diff --git a/webapp/src/components/__snapshots__/Header.test.tsx.snap b/webapp/src/components/__snapshots__/Header.test.tsx.snap index 5c103da7e..ecc83b920 100644 --- a/webapp/src/components/__snapshots__/Header.test.tsx.snap +++ b/webapp/src/components/__snapshots__/Header.test.tsx.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`
should take a snapshot 1`] = ` +exports[`
> should take a snapshot 1`] = `
@@ -10,60 +10,617 @@ exports[`should take a snapshot 1`] = ` class="MuiStack-root css-mocked-MuiStack-root" >

- Welcome to the - CV Manager Website + Welcome to the undefined CV Manager

- This application shows the physical location and message counts for each RSU installed by the Colorado Department of Transportation at various road sites throughout Colorado. + This application helps organizations manage and monitor deployed RSUs, monitor and detect issues with connected intersections, and view numerous data types all in one application.

+

+ Organizations and Profiles +

- The map on this website will represent the location of each RSU with a red, green, or yellow dot (shown below). A green dot represents an RSU that is online. A yellow dot represents an RSU that is offline but was recently active. A red dot represents an RSU that is not currently online. + This application uses organizations to manage permissions. All devices and users within the CV Manager are associated with one or more organizations. When using the CV Manager, you can only view one organization at a time. At the top right corner, you will see the User Profile menu. This menu allows the user to change organizations, if they are a member of multiple, as well as logging out of the application.

RSU Statuses Shown on Map +

+ Permissions +

- Clicking on the RSU will give the following information: IP address, online status, time last online, milepost number, serial number, and number of message counts (shown below). + There are 3 roles within the CV Manager: User, Operator, and Admin. Some users can also be granted Super User permissions, which enables access to all resources within an organization and increases their ability to move resources between organizations. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ User Roles and Permissions (By Organization) +
+ + User + + Operator + + Admin +
+ View Map Data (including RSUs) + + + + + + +
+ Register, Manage, and Configure RSUs + + + + + + +
+ Manage Intersection Settings and Notifications + + + + + + +
+ Manage Users + + + + + + +
+ Add and Remove Resources Within Organizations + + + + + + +
+
+

+ Enabled Features +

+

+ The CV Manager can have different feature sets enabled or disabled. In this application, the following features are currently enabled or disabled as shown below: +

+
    +
  • + RSU Monitoring and Configuration: ENABLED +
  • +
  • + Intersection Map/Dashboard: ENABLED +
  • +
  • + WZDx Viewer: ENABLED +
  • +
  • + HAAS Alert Viewer: ENABLED +
  • +
+

+ + Map Dashboard + +

+

+ The map dashboard is composed of a Mapbox map (background), the + + Map Layer Menu + + (red), and + + RSU Status and Message Counts Menu + + (green).

RSU Popup with Data +

+ Map Layers +

+

+ The menu on the left contains three sections: + + Map Layers + + , + + RSU Filters + + , and + + RSU Configuration + + . The Map Layers section allows users to visualize various data types. Available layers include: +

+
    +
  • + + RSU Viewer: + + Displays RSU locations and status. Selecting one opens a popup and side panel for configuration. Colors: +
      +
    • + Green = online and reporting +
    • +
    • + Yellow = recently offline +
    • +
    • + Red = offline for extended time +
    • +
    +
  • +
  • + + Heatmap: + + Displays a message-count heatmap +
  • +
  • + + V2X Message Viewer: + + Query messages for a map region +
  • +
  • + + WZDx Viewer: + + Displays work zone events +
  • +
  • + + Intersections: + + Shows connected intersections and IDs +
  • +
  • + + HAAS Alert Viewer: + + Query alert incidents by time +
  • +
+

+ The RSU Filters section allows filtering RSUs by vendor and status. The RSU Configuration section allows Operators/Admins to select RSUs on the map and configure them in bulk. +

+

+ Configuring RSUs (Requires Operator or Admin) +

- The menu on the right side of the webpage will allow users to select the time range and message type to be displayed for the RSUs. The menu will toggle between being hidden and visible each time the red X is clicked. The time frame can be changed by adjusting the date and time values in the date-time pickers. The top date-time picker represents the start time while the bottom represents the end time. Below the date-time pickers is a dropdown menu where the RSU message type can be selected. Each time a value is updated in either the date-time pickers or message selection dropdown the webpage will update accordingly. These elements are shown in the image below: + Selecting an RSU opens a popup and configuration menu. It displays IP address, online status, last report time, milepost, serial number, and message count. Depending on access, users can retrieve configurations, modify them, check/apply firmware updates, or reboot the RSU.

RSU Menu +
+ Message Forwarding Current Configuration +

- The table included in the menu will show the number of message counts for each RSU in the given time frame regardless of online status (shown below). The table can be sorted by RSU, road, or count by clicking on the desired column header. Each time a column header is clicked, it will toggle between sorting the data in an ascending/descending fashion. + Message forwarding rules come in three types:

+
    +
  • + TX (transmitted messages: TIM, MAP, SPAT, SSM) +
  • +
  • + RX (received messages: BSM, SRM, SDSM) +
  • +
  • + Generic (SNMP 4.1: applies to all messages) +
  • +
+

+ Each rule includes: +

+
    +
  • + Message Type +
  • +
  • + Destination IP +
  • +
  • + Port +
  • +
  • + Start / End date +
  • +
  • + Security header enabled +
  • +
  • + Active state +
  • +
  • + Delete button +
  • +
+
+ Message Forwarding Management +
+

+ Create forwarding rules by entering: +

+
    +
  • + Destination IP +
  • +
  • + Message type +
  • +
  • + Security header +
  • +
+
+ Firmware Management +
+

+ Check for firmware updates and apply if available. +

+
+ Reboot +
+

+ Reboot the selected RSU. +

+

+ RSU Status and Message Counts +

+

+ On the right side are two menus: + + RSU Status Menu + + and + + Message Count Menu + + . +

+

+ The RSU Status Menu lists all RSUs and their status. Users can print a full or error-only report. For each device you will find: +

+
    +
  • + Location +
  • +
  • + Online status (green / red / yellow) +
  • +
  • + SCMS certificate status +
  • +
  • + RSU IP address +
  • +
RSU Message Count Table

- Selecting 'Heat Map' on the navigation bar will show a heat map representing RSU message counts for the time range and message type selected in the menu (shown below). Any update to time range or message type in the menu will carry over between the heat map and RSU map. + The Message Count Menu filters RSU message counts by time range and message type. Changing any filter updates the map and table automatically.

CV Manager Heat Map

- Selecting 'Configure' on the navigation bar will allow users to perform certain actions to the RSU based on their role. An RSU must first be selected on the RSU Map before any options become available. Users will be able to perform one or more of the following: pull current message forwarding configuration, add/delete message forwarding configurations, and perform an RSU reboot. + A table below shows the number of messages from each RSU, sortable by RSU name, road, or message count.

CV Manager Configuration Page +

+ The RSU Configuration section allows applying configuration changes to multiple RSUs based on a selected geographic region. +

-
-

- - Hard Braking Color Guide - -

-
-
-
-

- 0-249 Hard Brakes -

-
-
-
-

- 250-499 Hard Brakes -

-
-
-
-

- 500-749 Hard Brakes -

-
-
-
-

- 750+ Hard Brakes -

-
-
-
-

- Determined by a weekly average -

-
-
-`; diff --git a/webapp/src/components/__snapshots__/RsuErrorSummary.test.tsx.snap b/webapp/src/components/__snapshots__/RsuErrorSummary.test.tsx.snap deleted file mode 100644 index 261c5b8ba..000000000 --- a/webapp/src/components/__snapshots__/RsuErrorSummary.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should take a snapshot 1`] = ` -