From e5ec40258162d4170dfd174bbd04d9d77f86d8d0 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Fri, 20 Dec 2024 12:54:27 +0300 Subject: [PATCH] Update import script (#45) * Update importer script version * Refactor sample.env update envs * Update to work with changes in importer * Update to work with changes in importer * Update location type coding system url * Escape file path * Change default python interpreter path * Add logging to import endpoint * Increase concurent jobs to 5 * Update importer * Refactor a few logging rules * Remove notes * Update yarn version to v4.4 * Set version of node that should be used * Make bulk upload queue configurable * Update test * return on error if product or inventory list ids not provided * Update express-session * Add tests * Fix tests for the job module * Code cleanup and test regression fixes * Fix lint issues --- .env.sample | 32 +- .gitignore | 9 + .nvmrc | 1 + build/importer/.gitignore | 125 +- build/importer/README.md | 86 +- build/importer/config/sample_config.py | 17 - build/importer/config/settings.py | 42 - build/importer/csv/import/product.csv | 6 +- build/importer/csv/setup/roles.csv | 1 + build/importer/csv/users.csv | 6 +- .../importer/{config => importer}/__init__.py | 0 build/importer/importer/builder.py | 1018 +++++++ .../{services => importer/config}/__init__.py | 0 build/importer/importer/config/settings.py | 51 + build/importer/importer/request.py | 30 + .../{utils => importer/services}/__init__.py | 0 .../services/fhir_keycloak_api.py | 132 +- build/importer/importer/users.py | 380 +++ build/importer/importer/utils.py | 393 +++ .../json_payloads/group_list_payload.json | 31 + .../json_payloads/locations_payload.json | 117 +- .../json_payloads/product_group_payload.json | 12 +- build/importer/main.py | 1818 +----------- build/importer/pytest.ini | 9 + build/importer/requirements.txt | 6 +- build/importer/stub_data_gen/orgs-stup-gen.py | 35 - .../importer/stub_data_gen/users-stub-gen.py | 42 - build/importer/tests/__init__.py | 0 .../tests/{test_main.py => test_builder.py} | 915 ++---- build/importer/tests/test_users.py | 429 +++ build/importer/tests/test_utils.py | 196 ++ build/importer/utils/location_process.py | 91 - jest.setup.js | 27 +- package.json | 13 +- src/app/dollar-imports/{ => helpers}/job.ts | 91 +- src/app/dollar-imports/helpers/middleware.ts | 31 + src/app/dollar-imports/{ => helpers}/queue.ts | 8 +- src/app/dollar-imports/{ => helpers}/utils.ts | 36 +- .../dollar-imports/importerConfigWriter.ts | 31 - src/app/dollar-imports/index.ts | 64 +- src/app/dollar-imports/middleware.ts | 32 - .../tests/dollar-imports.test.ts | 143 + .../dollar-imports/tests/fixtures/fixtures.ts | 250 ++ .../tests/importerConfigWriter.test.ts | 18 - src/app/dollar-imports/tests/job.test.ts | 84 +- src/app/helpers/utils.ts | 4 + src/app/index.ts | 4 +- src/app/tests/index.test.ts | 45 +- src/configs/envs.ts | 5 + src/constants.ts | 1 + yarn.lock | 2510 +++++++++++------ 51 files changed, 5461 insertions(+), 3966 deletions(-) create mode 100644 .nvmrc delete mode 100644 build/importer/config/sample_config.py delete mode 100644 build/importer/config/settings.py rename build/importer/{config => importer}/__init__.py (100%) create mode 100644 build/importer/importer/builder.py rename build/importer/{services => importer/config}/__init__.py (100%) create mode 100644 build/importer/importer/config/settings.py create mode 100644 build/importer/importer/request.py rename build/importer/{utils => importer/services}/__init__.py (100%) rename build/importer/{ => importer}/services/fhir_keycloak_api.py (58%) create mode 100644 build/importer/importer/users.py create mode 100644 build/importer/importer/utils.py create mode 100644 build/importer/json_payloads/group_list_payload.json create mode 100644 build/importer/pytest.ini delete mode 100644 build/importer/stub_data_gen/orgs-stup-gen.py delete mode 100644 build/importer/stub_data_gen/users-stub-gen.py create mode 100644 build/importer/tests/__init__.py rename build/importer/tests/{test_main.py => test_builder.py} (52%) create mode 100644 build/importer/tests/test_users.py create mode 100644 build/importer/tests/test_utils.py delete mode 100644 build/importer/utils/location_process.py rename src/app/dollar-imports/{ => helpers}/job.ts (56%) create mode 100644 src/app/dollar-imports/helpers/middleware.ts rename src/app/dollar-imports/{ => helpers}/queue.ts (67%) rename src/app/dollar-imports/{ => helpers}/utils.ts (74%) delete mode 100644 src/app/dollar-imports/importerConfigWriter.ts delete mode 100644 src/app/dollar-imports/middleware.ts create mode 100644 src/app/dollar-imports/tests/dollar-imports.test.ts create mode 100644 src/app/dollar-imports/tests/fixtures/fixtures.ts delete mode 100644 src/app/dollar-imports/tests/importerConfigWriter.test.ts diff --git a/.env.sample b/.env.sample index 33a70ca..414e083 100644 --- a/.env.sample +++ b/.env.sample @@ -1,12 +1,26 @@ NODE_ENV=development +# authentication EXPRESS_OPENSRP_ACCESS_TOKEN_URL=https://reveal-stage.smartregister.org/opensrp/oauth/token EXPRESS_OPENSRP_AUTHORIZATION_URL=https://reveal-stage.smartregister.org/opensrp/oauth/authorize +EXPRESS_KEYCLOAK_LOGOUT_URL=https://keycloak-stage.smartregister.org/auth/realms/reveal-stage/protocol/openid-connect/logout EXPRESS_OPENSRP_CALLBACK_URL=http://localhost:3000/oauth/callback/OpenSRP/ EXPRESS_OPENSRP_OAUTH_STATE=opensrp EXPRESS_OPENSRP_CLIENT_ID=hunter2 EXPRESS_OPENSRP_CLIENT_SECRET=hunter2 -EXPRESS_OPENSRP_SCOPES ="openid,profile" +EXPRESS_OPENSRP_SCOPES=openid,profile +EXPRESS_SERVER_LOGOUT_URL=http://localhost:3000/logout +# optional -> kills opensrp web server session, for instance not needed when auth server is keycloak +EXPRESS_OPENSRP_LOGOUT_URL=https://reveal-stage.smartregister.org/opensrp/logout.do +EXPRESS_SESSION_LOGIN_URL=/login +EXPRESS_FRONTEND_OPENSRP_CALLBACK_URL=http://localhost:3000/fe/oauth/callback/opensrp +EXPRESS_FRONTEND_LOGIN_URL=/fe/login +EXPRESS_ALLOW_TOKEN_RENEWAL=true +# time in seconds 3*60*60 = 10800 +EXPRESS_MAXIMUM_SESSION_LIFE_TIME=10800 + + +EXPRESS_OPENSRP_SERVER_URL=http://localhost:8081/fhir EXPRESS_PORT=3000 EXPRESS_SESSION_NAME=reveal-session @@ -16,19 +30,6 @@ EXPRESS_REACT_BUILD_PATH=/home/mosh/ona/reveal-frontend/build EXPRESS_SESSION_FILESTORE_PATH=/tmp/express-sessions EXPRESS_PRELOADED_STATE_FILE=/tmp/revealState.json -EXPRESS_SESSION_LOGIN_URL=/login -EXPRESS_FRONTEND_OPENSRP_CALLBACK_URL=http://localhost:3000/fe/oauth/callback/opensrp -EXPRESS_FRONTEND_LOGIN_URL=/fe/login - -EXPRESS_ALLOW_TOKEN_RENEWAL=true -# time in seconds 3*60*60 = 10800 -EXPRESS_MAXIMUM_SESSION_LIFE_TIME=10800 - -EXPRESS_SERVER_LOGOUT_URL=http://localhost:3000/logout -# optional -> kills opensrp web server session, for instance not needed when auth server is keycloak -EXPRESS_OPENSRP_LOGOUT_URL=https://reveal-stage.smartregister.org/opensrp/logout.do -EXPRESS_KEYCLOAK_LOGOUT_URL=https://keycloak-stage.smartregister.org/auth/realms/reveal-stage/protocol/openid-connect/logout - EXPRESS_MAXIMUM_LOGS_FILE_SIZE=5242880 # 5MB EXPRESS_MAXIMUM_LOG_FILES_NUMBER=5 EXPRESS_LOGS_FILE_PATH=/home/.express/reveal-express-server.log @@ -45,4 +46,5 @@ EXPRESS_REDIS_SENTINEL_CONFIG='{"name":"master","sentinelUsername":"u_name","sen # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to. `Map`. EXPRESS_RESPONSE_HEADERS='{"Report-To":"{ \"group\": \"csp-endpoint\", \"max_age\": 10886400, \"endpoints\": [{ \"url\": \"https://example.com/endpoint\" }] }", "Access-Control-Allow-Headers": "GET"}' - +EXPRESS_PYTHON_INTERPRETER_PATH=/usr/bin/python +EXPRESS_BULK_UPLOAD_REDIS_QUEUE="fhir-import" diff --git a/.gitignore b/.gitignore index f51d63b..798faca 100755 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,12 @@ yarn-error.log* # logs logs *.log + +# yarn +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..80a9956 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.16.0 diff --git a/build/importer/.gitignore b/build/importer/.gitignore index 21deed3..82639c7 100644 --- a/build/importer/.gitignore +++ b/build/importer/.gitignore @@ -1,41 +1,11 @@ config.py +importer.log -sampleData -.idea # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt @@ -55,76 +25,11 @@ coverage.xml .pytest_cache/ cover/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ +.python-version # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -134,33 +39,9 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ \ No newline at end of file diff --git a/build/importer/README.md b/build/importer/README.md index d8bbdd5..19d80a2 100644 --- a/build/importer/README.md +++ b/build/importer/README.md @@ -12,67 +12,95 @@ This script is used to setup keycloak roles and groups. It takes in a csv file w - `csv_file` : (Required) The csv file with the list of roles - `group` : (Not required) This is the actual group name. If not passed then the roles will just be created but not assigned to any group - `roles_max` : (Not required) This is the maximum number of roles to pull from the api. The default is set to 500. If the number of roles in your setup is more than this you will need to change this value - +- `default_groups` : (Not Required) This is a boolean value to turn on and off the assignment of default roles. The default value is `true` ### To run script + 1. Create virtualenv 2. Install requirements.txt - `pip install -r requirements.txt` -3. Create a `config/config.py` file. The `config/sample_config.py` is an example of what this should look like. Populate it with the right credentials, you can either provide an access token or client credentials. Ensure that the user whose details you provide in this config file has the necessary permissions/privilleges. +3. Set up your .env file, see sample below. Populate it with the right credentials, you can either provide an access token or client credentials. Ensure that the user whose details you provide in this config file has the necessary permissions/privileges. 4. Run script - `python3 main.py --setup roles --csv_file csv/setup/roles.csv --group Supervisor` 5. If you are running the script without `https` setup e.g locally or a server without https setup, you will need to set the `OAUTHLIB_INSECURE_TRANSPORT` environment variable to 1. For example `export OAUTHLIB_INSECURE_TRANSPORT=1 && python3 main.py --setup roles --csv_file csv/setup/roles.csv --group OpenSRP_Provider --log_level debug` 6. You can turn on logging by passing a `--log_level` to the command line as `info`, `debug` or `error`. For example `python3 main.py --setup roles --csv_file csv/setup/roles.csv --group Supervisor --log_level debug` +#### Sample .env file + +``` +client_id = 'example-client-id' +client_secret = 'example-client-secret' +fhir_base_url = 'https://example.smartregister.org/fhir' +keycloak_url = 'https://keycloak.smartregister.org/auth' + +# access token for access to where product images are remotely stored +product_access_token = 'example-product-access-token' + +# if using resource owner credentials (i.e importer handles getting authentication by itself) +# This has greater precedence over the access and refresh tokens if supplied +username = 'example-username' +password = 'example-password' + +# if embedding importer into a service that already does the authentication +access_token = 'example-access-token' +``` # FHIR Resource CSV Importer -This script takes in a csv file with a list of resources, builds the payloads +This script takes in a csv file with a list of resources, builds the payloads and then posts them to the API for creation ### To run script + 1. Create virtualenv 2. Install requirements.txt - `pip install -r requirements.txt` -3. Create a `config.py` file. The `sample_config.py` is an example of what this should look like. Populate it with the right credentials +3. Create a `config.py` file. The `sample_config.py` is an example of what this should look like. Populate it with the right credentials 4. Run script - `python3 main.py --csv_file csv/locations.csv --resource_type locations` 5. You can turn on logging by passing a `--log_level` to the command line as `info`, `debug` or `error`. For example `python3 main.py --csv_file csv/locations.csv --resource_type locations --log_level info` 6. There is a progress bar that shows the read_csv and build_payload progress as it is going on 7. You can get only the response from the api after the import is done by passing `--only_response true` - See example csvs in the csv folder ## To test To run all tests + ```console $ pytest ``` + To run specific tests + ```console $ pytest path/to/test_file.py::TestClass::test_function ``` To run tests and generate a coverage report + ```console $ pytest --junitxml=coverage.html --cov=importer --cov-report=html ``` + The coverage report `coverage.html` will be at the working directory ## How to use it ### 1. Create locations in bulk + - Run `python3 main.py --csv_file csv/locations/locations_min.csv --resource_type locations --log_level info` - See example csv [here](/importer/csv/locations/locations_min.csv) -- The first two columns __name__ and __status__ is the minimum required -- If the csv file has only the required columns, (e.g. [locations_min.csv](/importer/csv/locations/locations_min.csv)) the __id__ and __method__ are set to __generating a new unique_uuid__ and a default value __create__ method respectively +- The first two columns **name** and **status** is the minimum required +- If the csv file has only the required columns, (e.g. [locations_min.csv](/importer/csv/locations/locations_min.csv)) the **id** and **method** are set to **generating a new unique_uuid** and a default value **create** method respectively - [locations_full](/importer/csv/locations/locations_full.csv) shows more options available - The third column is the request method, can be either create or update. Default is set to create -- The fourth column is the id, which is required when updating -- The fifth and sixth columns are parentName and parentID,respectively +- The fourth column is the id, which is required when updating +- The fifth and sixth columns are parentName and parentID,respectively - The seventh and eighth columns are the location's type and typeCode, respectively - The ninth column is the administrative level, that shows the hierarchical level of the location. Root location would have a `level 0` and all child locations will have a level `parent_admin_level + 1` - The tenth and eleventh columns are the location's physicalType and physicalTypeCode, respectively +- You can pass in `--location_type_coding_system` to define your own location type coding system url (not required) ### 2. Create users in bulk + - Run `python3 main.py --csv_file csv/users.csv --resource_type users --log_level info` - See example csv [here](/importer/csv/users.csv) - First four columns are firstName, lastName, Username and email. Username and email need to be unique @@ -83,41 +111,46 @@ The coverage report `coverage.html` will be at the working directory - The last two columns are the `ApplicationID` and `password` ### 3. Create organizations in bulk + - Run `python3 main.py --csv_file csv/organizations/organizations_min.csv --resource_type organizations --log_level info` - See example csv [here](/importer/csv/organizations/organizations_min.csv) -- The first column __name__ is the only one required -- If the csv file has only the required column, (e.g. [organizations_min.csv](/importer/csv/organizations/organizations_min.csv)) the __id__ , __active__, and __method__ are set to __generating a new unique_uuid__ and the default values __create__ and __true__ respectively +- The first column **name** is the only one required +- If the csv file has only the required column, (e.g. [organizations_min.csv](/importer/csv/organizations/organizations_min.csv)) the **id** , **active**, and **method** are set to **generating a new unique_uuid** and the default values **create** and **true** respectively - [organizations_full](/importer/csv/organizations/organizations_full.csv) shows more options available - The third column is the request method, can be either create or update. Default is set to create - The fourth column is the id, which is required when updating - The fifth columns in the identifier, in some cases this is different from the id ### 4. Create care teams in bulk + - Run `python3 main.py --csv_file csv/careteams/careteam_full.csv --resource_type careTeams --log_level info` - See example csv [here](/importer/csv/careteams/careteam_full.csv) -- The first column __name__ is the only one required +- The first column **name** is the only one required - The third column is the request method, can be either create or update. Default is set to create - The fourth column is the id, which is required when updating - The fifth columns is the identifier, in some cases this is different from the id - The sixth column is the organizations. This is only useful when you want to assign organizations when creating and updating careTeams. The format expected is a string like `orgId1:orgName1|orgId2:orgName2|orgId3:orgNam3` - The seventh column is the participants. This is only useful when you want to assign users when creating and updating careTeams. The format expected is a string like `userId1:fullName1|userId2:fullName2|userId3:fullName3` - ### 5. Assign locations to parent locations + - Run `python3 main.py --csv_file csv/locations/locations_full.csv --resource_type locations --log_level info` - See example csv [here](/importer/csv/locations/locations_full.csv) -- Adding the last two columns __parentID__ and __parentName__ will ensure the locations are assigned the right parent both during creation or updating +- Adding the last two columns **parentID** and **parentName** will ensure the locations are assigned the right parent both during creation or updating ### 6. Assign organizations to locations + - Run `python3 main.py --csv_file csv/organizations/organizations_locations.csv --assign organizations-Locations --log_level info` - See example csv [here](/importer/csv/organizations/organizations_locations.csv) ### 7. Assign users to organizations + - Run `python3 main.py --csv_file csv/practitioners/users_organizations.csv --assign users-organizations --log_level info` - See example [here](/importer/csv/practitioners/users_organizations.csv) -- The first two columns are __name__ and __id__ of the practitioner, while the last two columns are the __name__ and __id__ of the organization +- The first two columns are **name** and **id** of the practitioner, while the last two columns are the **name** and **id** of the organization ### 8. Delete duplicate Practitioners on HAPI + - Run `python3 main.py --csv_file csv/users.csv --setup clean_duplicates --cascade_delete true --log_level info` - This should be used very carefully and in very special circumstances such as early stages of server setup. Avoid usage in active production environments as it will actually delete FHIR resources - It is recommended to first run with cascade_delete set to false in order to see if there are any linked resources which will also be deleted. Also any resources that are actually deleted are only soft deleted and can be recovered @@ -127,30 +160,37 @@ The coverage report `coverage.html` will be at the working directory - Set `cascade_delete` to True or False if you would like to automatically delete any linked resources. If you set it to False, and there are any linked resources, then the resources will NOT be deleted ### 9. Export resources from API endpoint to CSV file + - Run `python3 main.py --export_resources True --parameter _lastUpdated --value gt2023-08-01 --limit 20 --resource_type Location --log_level info` - `export_resources` can either be True or False, checks if it is True and exports the resources -- The `parameter` is used as a filter for the resources. The set default parameter is "_lastUpdated", other examples include, "name" +- The `parameter` is used as a filter for the resources. The set default parameter is "\_lastUpdated", other examples include, "name" - The `value` is where you pass the actual parameter value to filter the resources. The set default value is "gt2023-01-01", other examples include, "Good Health Clinic 1" - The `limit` is the number of resources exported at a time. The set default value is '1000' - Specify the `resource_type` you want to export, different resource_types are exported to different csv_files - The csv_file containing the exported resources is labelled using the current time, to know when the resources were exported for example, csv/exports/2024-02-21-12-21-export_Location.csv ### 10. Import products from openSRP 1 -- Run `python3 main.py --csv_file csv/import/product.csv --setup products --log_level info` + +- Run `python3 main.py --csv_file csv/import/product.csv --setup products --list_resource_id 123 --log_level info` - See example csv [here](/importer/csv/import/product.csv) -- This creates a Group resource for each product imported -- The first two columns __name__ and __active__ is the minimum required -- The last column __imageSourceUrl__ contains a url to the product image. If this source requires authentication, then you need to provide the `product_access_token` in the config file. The image is added as a binary resource and referenced in the product's Group resource +- This creates a Group resource for each product imported, a Binary resource for any products with an image, and a List resource with references to all the Group and Binary resources created +- The first two columns **name** and **active** is the minimum required +- The last column **imageSourceUrl** contains a url to the product image. If this source requires authentication, then you need to provide the `product_access_token` in the config file. The image is added as a binary resource and referenced in the product's Group resource +- You can pass in a `list_resource_id` to be used as the identifier for the List resource, or you can leave it empty and a random uuid will be generated ### 11. Import inventories from openSRP 1 -- Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --log_level info` + +- Run `python3 main.py --csv_file csv/import/inventory.csv --setup inventories --list_resource_id 123 --log_level info` - See example csv [here](/importer/csv/import/inventory.csv) - This creates a Group resource for each inventory imported -- The first two columns __name__ and __active__ is the minimum required +- The first two columns **name** and **active** is the minimum required - Adding a value to the Location column will create a separate List resource (or update) that links the inventory to the provided location resource +- A separate List resource with references to all the Group and List resources generated is also created +- You can pass in a `list_resource_id` to be used as the identifier for the (reference) List resource, or you can leave it empty and a random uuid will be generated ### 12. Import JSON resources from file -- Run `python3 main.py --bulk_import True --json_file tests/fhir_sample.json --chunk_size 500000 --sync sort --resources_count 100 --log_level info` + +- Run `python3 main.py --bulk_import True --json_file tests/json/sample.json --chunk_size 500000 --sync sort --resources_count 100 --log_level info` - This takes in a file with a JSON array, reads the resources from the array in the file and posts them to the FHIR server - `bulk_import` (Required) must be set to True - `json_file` (Required) points to the file with the json array. The resources in the array need to be separated by a single comma (no spaces) and the **"id"** must always be the first attribute in the resource object. This is what the code uses to identify the beginning and end of resources diff --git a/build/importer/config/sample_config.py b/build/importer/config/sample_config.py deleted file mode 100644 index 3adbe8f..0000000 --- a/build/importer/config/sample_config.py +++ /dev/null @@ -1,17 +0,0 @@ -client_id = 'example-client-id' -client_secret = 'example-client-secret' -fhir_base_url = 'https://example.smartregister.org/fhir' -keycloak_url = 'https://keycloak.smartregister.org/auth' - -# access token for access to where product images are remotely stored -product_access_token = 'example-product-access-token' - -# if using resource owner credentials (i.e importer handles getting authentication by itself) -# This has greater precedence over the access and refresh tokens if supplied -username = 'example-username' -password = 'example-password' - -# if embedding importer into a service that already does the authentication -access_token = 'example-access-token' -refresh_token = 'example-refresh-token' - diff --git a/build/importer/config/settings.py b/build/importer/config/settings.py deleted file mode 100644 index afebcd8..0000000 --- a/build/importer/config/settings.py +++ /dev/null @@ -1,42 +0,0 @@ -import importlib -import sys -import logging.config - - -from services.fhir_keycloak_api import FhirKeycloakApi, FhirKeycloakApiOptions, ExternalAuthenticationOptions, \ - InternalAuthenticationOptions -from config.config import client_id, client_secret, fhir_base_url, keycloak_url, realm - - -def dynamic_import(variable_name): - try: - config_module = importlib.import_module("config.config") - value = getattr(config_module, variable_name, None) - return value - except: - logging.error("Unable to import the configuration!") - sys.exit(1) - - -username = dynamic_import("username") -password = dynamic_import("password") - -# TODO - retrieving at and rt as args via the command line as well. -access_token = dynamic_import("access_token") -refresh_token = dynamic_import("refresh_token") -product_access_token = dynamic_import("product_access_token") - -authentication_options = None -if username is not None and password is not None: - authentication_options = InternalAuthenticationOptions(client_id=client_id, client_secret=client_secret, keycloak_base_uri=keycloak_url, realm=realm, user_username=username, user_password=password) -elif access_token is not None and refresh_token is not None: - authentication_options = ExternalAuthenticationOptions(client_id=client_id, client_secret=client_secret, keycloak_base_uri=keycloak_url, realm=realm, access_token=access_token, refresh_token=refresh_token) -else: - raise ValueError("Unable to get authentication parameters!") - -api_service_options = FhirKeycloakApiOptions(fhir_base_uri=fhir_base_url, authentication_options=authentication_options) -api_service = FhirKeycloakApi(api_service_options) - - - - diff --git a/build/importer/csv/import/product.csv b/build/importer/csv/import/product.csv index 7f49f22..47b90a8 100644 --- a/build/importer/csv/import/product.csv +++ b/build/importer/csv/import/product.csv @@ -1,3 +1,3 @@ -name,active,method,id,previousId,isAttractiveItem,availability,condition,appropriateUsage,accountabilityPeriod,imageSourceUrl -thermometer,true,create,1d86d0e2-bac8-4424-90ae-e2298900ac3c,10,true,yes,good,ok,12,https://ona.io/home/wp-content//uploads/2022/06/spotlight-fhir.png -sterilizer,true,create,,53209452,true,no,,,, \ No newline at end of file +name,active,method,id,materialNumber,previousId,isAttractiveItem,availability,condition,appropriateUsage,accountabilityPeriod,imageSourceUrl +thermometer,true,create,1d86d0e2-bac8-4424-90ae-e2298900ac3c,56dtdhdh,10,true,yes,good,ok,12,https://ona.io/home/wp-content//uploads/2022/06/spotlight-fhir.png +sterilizer,true,create,,6786kaiw,,true,no,,,, \ No newline at end of file diff --git a/build/importer/csv/setup/roles.csv b/build/importer/csv/setup/roles.csv index cdc8fce..64f7138 100644 --- a/build/importer/csv/setup/roles.csv +++ b/build/importer/csv/setup/roles.csv @@ -115,5 +115,6 @@ PUT_SERVICEREQUEST,, PUT_STRUCTUREMAP,, PUT_TASK,, WEB_CLIENT,, +ANDROID_CLIENT,, EDIT_KEYCLOAK_USERS,TRUE,manage-users|query-users VIEW_KEYCLOAK_USERS,TRUE,view-users|query-users|query-groups diff --git a/build/importer/csv/users.csv b/build/importer/csv/users.csv index 4f52e3a..1e4f310 100644 --- a/build/importer/csv/users.csv +++ b/build/importer/csv/users.csv @@ -1,4 +1,4 @@ firstName,lastName,username,email,userId,userType,enableUser,keycloakGroupId,keycloakGroupName,appId,password -Jane,Doe,Janey,jdoe@example.com,,Practitioner,true,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word -John,Doe,Johny,jodoe@example.com,,Practitioner,true,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word -Jenn,Doe,Jenn,jendoe@example.com,99d54e3c-c26f-4500-a7f9-3f4cb788673f,Supervisor,false,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word +Jane,Doe,janey,jdoe@example.com,,Practitioner,true,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word +John,Doe,johny,jodoe@example.com,,Practitioner,true,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word +Jenn,Doe,jenn,jendoe@example.com,99d54e3c-c26f-4500-a7f9-3f4cb788673f,Supervisor,false,a715b562-27f2-432a-b1ba-e57db35e0f93,test,demo,pa$$word diff --git a/build/importer/config/__init__.py b/build/importer/importer/__init__.py similarity index 100% rename from build/importer/config/__init__.py rename to build/importer/importer/__init__.py diff --git a/build/importer/importer/builder.py b/build/importer/importer/builder.py new file mode 100644 index 0000000..57f037f --- /dev/null +++ b/build/importer/importer/builder.py @@ -0,0 +1,1018 @@ +import base64 +import json +import logging +import os +import pathlib +import uuid + +import click +import magic +import requests + +from importer.config.settings import (api_service, fhir_base_url, + product_access_token) +from importer.request import handle_request + +dir_path = str(pathlib.Path(__file__).parent.resolve()) +json_path = "/".join([dir_path, "../json_payloads/"]) + + +def identify_coding_object_index(array, current_system): + for index, value in enumerate(array): + list_of_systems = value["coding"][0]["system"] + if current_system in list_of_systems: + return index + else: + return -1 + + +def get_base_url(): + return api_service.fhir_base_uri + + +def check_parent_admin_level(location_parent_id): + base_url = get_base_url() + resource_url = "/".join([base_url, "Location", location_parent_id]) + + response = handle_request("GET", "", resource_url) + obj = json.loads(response[0]) + if "type" in obj: + response_type = obj["type"] + current_system = "administrative-level" + if current_system: + index = identify_coding_object_index(response_type, current_system) + if index >= 0: + code = obj["type"][index]["coding"][0]["code"] + admin_level = str(int(code) + 1) + return admin_level + else: + return None + else: + return None + + +def extract_matches(resource_list): + team_map = {} + with click.progressbar( + resource_list, label="Progress::Extract matches " + ) as extract_progress: + for resource in extract_progress: + group_name, group_id, item_name, item_id = resource + if group_id.strip() and item_id.strip(): + if group_id not in team_map.keys(): + team_map[group_id] = [item_id + ":" + item_name] + else: + team_map[group_id].append(item_id + ":" + item_name) + else: + logging.error("Missing required id: Skipping " + str(resource)) + return team_map + + +# custom extras for organizations +def organization_extras(resource, payload_string): + try: + _, org_active, *_ = resource + except ValueError: + org_active = "true" + + try: + payload_string = payload_string.replace("$active", org_active) + except IndexError: + payload_string = payload_string.replace("$active", "true") + return payload_string + + +# custom extras for locations +def location_extras(resource, payload_string, location_coding_system): + try: + ( + locationName, + *_, + location_parent_name, + location_parent_id, + location_type, + location_type_code, + location_admin_level, + location_physical_type, + location_physical_type_code, + longitude, + latitude, + ) = resource + except ValueError: + location_parent_name = "parentName" + location_parent_id = "ParentId" + location_type = "type" + location_type_code = "typeCode" + location_admin_level = "adminLevel" + location_physical_type = "physicalType" + location_physical_type_code = "physicalTypeCode" + longitude = "longitude" + + try: + if location_parent_id and location_parent_id != "parentId": + payload_string = payload_string.replace("$parentID", location_parent_id) + if not location_parent_name or location_parent_name == "parentName": + obj = json.loads(payload_string) + del obj["resource"]["partOf"]["display"] + payload_string = json.dumps(obj, indent=4) + else: + payload_string = payload_string.replace( + "$parentName", location_parent_name + ) + else: + obj = json.loads(payload_string) + del obj["resource"]["partOf"] + payload_string = json.dumps(obj, indent=4) + except IndexError: + obj = json.loads(payload_string) + del obj["resource"]["partOf"] + payload_string = json.dumps(obj, indent=4) + + try: + payload_string = payload_string.replace("$t_system", location_coding_system) + if location_type and location_type != "type": + payload_string = payload_string.replace("$t_display", location_type) + if location_type_code and location_type_code != "typeCode": + payload_string = payload_string.replace("$t_code", location_type_code) + else: + obj = json.loads(payload_string) + payload_type = obj["resource"]["type"] + current_system = "location-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + except IndexError: + obj = json.loads(payload_string) + payload_type = obj["resource"]["type"] + current_system = "location-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + + try: + if location_admin_level and location_admin_level != "adminLevel": + payload_string = payload_string.replace( + "$adminLevelCode", location_admin_level + ) + else: + if location_admin_level in resource: + admin_level = check_parent_admin_level(location_parent_id) + if admin_level: + payload_string = payload_string.replace( + "$adminLevelCode", admin_level + ) + else: + obj = json.loads(payload_string) + obj_type = obj["resource"]["type"] + current_system = "administrative-level" + index = identify_coding_object_index(obj_type, current_system) + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + else: + obj = json.loads(payload_string) + obj_type = obj["resource"]["type"] + current_system = "administrative-level" + index = identify_coding_object_index(obj_type, current_system) + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + except IndexError: + if location_admin_level in resource: + admin_level = check_parent_admin_level(location_parent_id) + if admin_level: + payload_string = payload_string.replace("$adminLevelCode", admin_level) + else: + obj = json.loads(payload_string) + obj_type = obj["resource"]["type"] + current_system = "administrative-level" + index = identify_coding_object_index(obj_type, current_system) + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + else: + obj = json.loads(payload_string) + obj_type = obj["resource"]["type"] + current_system = "administrative-level" + index = identify_coding_object_index(obj_type, current_system) + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + + try: + if location_physical_type and location_physical_type != "physicalType": + payload_string = payload_string.replace( + "$pt_display", location_physical_type + ) + if ( + location_physical_type_code + and location_physical_type_code != "physicalTypeCode" + ): + payload_string = payload_string.replace( + "$pt_code", location_physical_type_code + ) + else: + obj = json.loads(payload_string) + del obj["resource"]["physicalType"] + # also remove from type[] + payload_type = obj["resource"]["type"] + current_system = "location-physical-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + except IndexError: + obj = json.loads(payload_string) + del obj["resource"]["physicalType"] + payload_type = obj["resource"]["type"] + current_system = "location-physical-type" + index = identify_coding_object_index(payload_type, current_system) + if index >= 0: + del obj["resource"]["type"][index] + payload_string = json.dumps(obj, indent=4) + + # check if type is empty + obj = json.loads(payload_string) + _type = obj["resource"]["type"] + if not _type: + del obj["resource"]["type"] + payload_string = json.dumps(obj, indent=4) + + try: + if longitude and longitude != "longitude": + payload_string = payload_string.replace('"$longitude"', longitude).replace( + '"$latitude"', latitude + ) + else: + obj = json.loads(payload_string) + del obj["resource"]["position"] + payload_string = json.dumps(obj, indent=4) + except IndexError: + obj = json.loads(payload_string) + del obj["resource"]["position"] + payload_string = json.dumps(obj, indent=4) + + return payload_string + + +# custom extras for careTeams +def care_team_extras(resource, payload_string, f_type): + orgs_list = [] + participant_list = [] + elements = [] + elements2 = [] + + try: + *_, organizations, participants = resource + except ValueError: + organizations = "organizations" + participants = "participants" + + if organizations and organizations != "organizations": + elements = organizations.split("|") + else: + logging.info("No organizations") + + if participants and participants != "participants": + elements2 = participants.split("|") + else: + logging.info("No participants") + + if "orgs" in f_type: + for org in elements: + y = {} + x = org.split(":") + y["reference"] = "Organization/" + str(x[0]) + y["display"] = str(x[1]) + orgs_list.append(y) + + z = { + "role": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "394730007", + "display": "Healthcare related organization", + } + ] + } + ], + "member": {}, + } + z["member"]["reference"] = "Organization/" + str(x[0]) + z["member"]["display"] = str(x[1]) + participant_list.append(z) + + if len(participant_list) > 0: + obj = json.loads(payload_string) + obj["resource"]["participant"] = participant_list + obj["resource"]["managingOrganization"] = orgs_list + payload_string = json.dumps(obj) + + if "users" in f_type: + if len(elements2) > 0: + elements = elements2 + for user in elements: + y = {"member": {}} + x = user.split(":") + y["member"]["reference"] = "Practitioner/" + str(x[0]) + y["member"]["display"] = str(x[1]) + participant_list.append(y) + + if len(participant_list) > 0: + obj = json.loads(payload_string) + obj["resource"]["participant"] = participant_list + payload_string = json.dumps(obj) + + return payload_string + + +def encode_image(image_file): + with open(image_file, "rb") as image: + image_b64_data = base64.b64encode(image.read()) + return image_b64_data + + +# This function takes in the source url of an image, downloads it, encodes it, +# and saves it as a Binary resource. It returns the id of the Binary resource if +# successful and 0 if failed +def save_image(image_source_url): + try: + headers = {"Authorization": "Bearer " + product_access_token} + except AttributeError: + headers = {} + + data = requests.get(url=image_source_url, headers=headers) + if not os.path.exists("images"): + os.makedirs("images") + + if data.status_code == 200: + with open("images/image_file", "wb") as image_file: + image_file.write(data.content) + + # get file type + mime = magic.Magic(mime=True) + file_type = mime.from_file("images/image_file") + + encoded_image = encode_image("images/image_file") + resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, image_source_url)) + payload = { + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "request": { + "method": "PUT", + "url": "Binary/" + resource_id, + "ifMatch": "1", + }, + "resource": { + "resourceType": "Binary", + "id": resource_id, + "contentType": file_type, + "data": str(encoded_image), + }, + } + ], + } + payload_string = json.dumps(payload, indent=4) + response = handle_request("POST", payload_string, get_base_url()) + if response.status_code == 200: + logging.info(response.text) + return resource_id + else: + logging.error("Error while creating Binary resource") + logging.error(response.text) + return 0 + else: + logging.error("Error while attempting to retrieve image") + logging.error(data) + return 0 + + +# custom extras for product import +def group_extras(resource, payload_string, group_type, created_resources): + payload_obj = json.loads(payload_string) + item_name = resource[0] + del_indexes = [] + del_identifier_indexes = [] + + GROUP_INDEX_MAPPING = { + "product_official_id_index": 0, + "product_secondary_id_index": 1, + "product_is_attractive_index": 0, + "product_is_available_index": 1, + "product_condition_index": 2, + "product_appropriate_usage_index": 3, + "product_accountability_period_index": 4, + "product_image_index": 5, + "inventory_official_id_index": 0, + "inventory_secondary_id_index": 1, + "inventory_usual_id_index": 2, + "inventory_member_index": 0, + "inventory_quantity_index": 0, + "inventory_unicef_section_index": 1, + "inventory_donor_index": 2, + } + + if group_type == "product": + ( + _, + active, + *_, + material_number, + previous_id, + is_attractive_item, + availability, + condition, + appropriate_usage, + accountability_period, + image_source_url, + ) = resource + + if active: + payload_obj["resource"]["active"] = active + else: + del payload_obj["resource"]["active"] + + if material_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["product_official_id_index"] + ]["value"] = material_number + else: + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["product_official_id_index"] + ) + + if previous_id: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["product_secondary_id_index"] + ]["value"] = previous_id + else: + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["product_secondary_id_index"] + ) + + if is_attractive_item: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_is_attractive_index"] + ]["valueBoolean"] = is_attractive_item + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_is_attractive_index"]) + + if availability: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_is_available_index"] + ]["valueCodeableConcept"]["text"] = availability + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_is_available_index"]) + + if condition: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_condition_index"] + ]["valueCodeableConcept"]["text"] = condition + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_condition_index"]) + + if appropriate_usage: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_appropriate_usage_index"] + ]["valueCodeableConcept"]["text"] = appropriate_usage + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_appropriate_usage_index"]) + + if accountability_period: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_accountability_period_index"] + ]["valueQuantity"]["value"] = accountability_period + else: + del_indexes.append( + GROUP_INDEX_MAPPING["product_accountability_period_index"] + ) + + if image_source_url: + image_binary = save_image(image_source_url) + if image_binary != 0: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["product_image_index"] + ]["valueReference"]["reference"] = ("Binary/" + image_binary) + created_resources.append("Binary/" + image_binary) + else: + logging.error( + "Unable to link the image Binary resource for product " + item_name + ) + del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) + else: + del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) + + elif group_type == "inventory": + ( + _, + active, + *_, + po_number, + serial_number, + usual_id, + actual, + product_id, + delivery_date, + accountability_date, + quantity, + unicef_section, + donor, + location, + ) = resource + + if active: + payload_obj["resource"]["active"] = bool(active) + else: + del payload_obj["resource"]["active"] + + if serial_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_official_id_index"] + ]["value"] = serial_number + else: + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_official_id_index"] + ) + + if po_number: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_secondary_id_index"] + ]["value"] = po_number + else: + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_secondary_id_index"] + ) + + if usual_id: + payload_obj["resource"]["identifier"][ + GROUP_INDEX_MAPPING["inventory_usual_id_index"] + ]["value"] = usual_id + else: + del_identifier_indexes.append( + GROUP_INDEX_MAPPING["inventory_usual_id_index"] + ) + + if actual: + payload_obj["resource"]["actual"] = bool(actual) + else: + del payload_obj["resource"]["actual"] + + if product_id: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["entity"]["reference"] = ("Group/" + product_id) + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["entity"]["reference"] = "Group/" + + if delivery_date: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["start"] = delivery_date + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["start"] = "" + + if accountability_date: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["end"] = accountability_date + else: + payload_obj["resource"]["member"][ + GROUP_INDEX_MAPPING["inventory_member_index"] + ]["period"]["end"] = "" + + if quantity: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["inventory_quantity_index"] + ]["valueQuantity"]["value"] = int(quantity) + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_quantity_index"]) + + if unicef_section: + payload_obj["resource"]["characteristic"][ + GROUP_INDEX_MAPPING["inventory_unicef_section_index"] + ]["valueCodeableConcept"]["text"] = unicef_section + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_unicef_section_index"]) + + if donor: + payload_obj["resource"]["characteristic"][2]["valueCodeableConcept"][ + "text" + ] = donor + else: + del_indexes.append(GROUP_INDEX_MAPPING["inventory_donor_index"]) + + else: + logging.info("Group type not defined") + + for x in reversed(del_indexes): + del payload_obj["resource"]["characteristic"][x] + for x in reversed(del_identifier_indexes): + del payload_obj["resource"]["identifier"][x] + + payload_string = json.dumps(payload_obj, indent=4) + return payload_string, created_resources + + +# This function is used to Capitalize the 'resource_type' +# and remove the 's' at the end, a version suitable with the API +def get_valid_resource_type(resource_type): + logging.debug("Modify the string resource_type") + modified_resource_type = resource_type[0].upper() + resource_type[1:-1] + return modified_resource_type + + +# This function gets the current resource version from the API +def get_resource(resource_id, resource_type): + if resource_type not in ["List", "Group"]: + resource_type = get_valid_resource_type(resource_type) + resource_url = "/".join([fhir_base_url, resource_type, resource_id]) + response = handle_request("GET", "", resource_url) + return json.loads(response[0])["meta"]["versionId"] if response[1] == 200 else "0" + + +def check_for_nulls(resource: list) -> list: + for index, value in enumerate(resource): + if len(value.strip()) < 1: + resource[index] = None + else: + resource[index] = value.strip() + return resource + + +# This function builds a json payload +# which is posted to the api to create resources +def build_payload( + resource_type, + resources, + resource_payload_file, + created_resources=None, + location_coding_system=None, +): + logging.info("Building request payload") + initial_string = """{"resourceType": "Bundle","type": "transaction","entry": [ """ + final_string = group_type = " " + + with open(resource_payload_file) as json_file: + payload_string = json_file.read() + + with click.progressbar( + resources, label="Progress::Building payload " + ) as build_payload_progress: + for resource in build_payload_progress: + logging.info("\t") + + resource = check_for_nulls(resource) + + try: + name, status, method, _id, *_ = resource + except ValueError: + name = resource[0] + status = "" if len(resource) == 1 else resource[1] + method = "create" + _id = str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) + + if method == "create": + version = "1" + if _id: + unique_uuid = identifier_uuid = _id + else: + unique_uuid = identifier_uuid = str( + uuid.uuid5(uuid.NAMESPACE_DNS, name) + ) + + if method == "update": + if _id: + version = get_resource(_id, resource_type) + + if version != "0": + unique_uuid = identifier_uuid = _id + else: + logging.info("Failed to get resource!") + raise ValueError("Trying to update a Non-existent resource") + else: + logging.info("The id is required!") + raise ValueError("The id is required to update a resource") + + # ps = payload_string + ps = ( + payload_string.replace("$name", name) + .replace("$unique_uuid", unique_uuid) + .replace("$identifier_uuid", identifier_uuid) + .replace("$version", version) + ) + + try: + ps = ps.replace("$status", status) + except IndexError: + ps = ps.replace("$status", "active") + + if resource_type == "organizations": + ps = organization_extras(resource, ps) + elif resource_type == "locations": + ps = location_extras(resource, ps, location_coding_system) + elif resource_type == "careTeams": + ps = care_team_extras(resource, ps, "orgs & users") + elif resource_type == "Group": + if "inventory" in resource_payload_file: + group_type = "inventory" + elif "product" in resource_payload_file: + group_type = "product" + else: + logging.error("Undefined group type") + ps, created_resources = group_extras( + resource, ps, group_type, created_resources + ) + + final_string = final_string + ps + "," + + final_string = initial_string + final_string[:-1] + " ] } " + if group_type == "product": + return final_string, created_resources + return final_string + + +def get_org_name(key, resource_list): + org_name = "" + for x in resource_list: + if x[1] == key: + org_name = x[0] + return org_name + + +def build_org_affiliation(resources, resource_list): + fp = """{"resourceType": "Bundle","type": "transaction","entry": [ """ + + with open(json_path + "organization_affiliation_payload.json") as json_file: + payload_string = json_file.read() + + with click.progressbar( + resources, label="Progress::Build payload " + ) as build_progress: + for key in build_progress: + rp = "" + unique_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, key)) + org_name = get_org_name(key, resource_list) + + rp = ( + payload_string.replace("$unique_uuid", unique_uuid) + .replace("$identifier_uuid", unique_uuid) + .replace("$version", "1") + .replace("$orgID", key) + .replace("$orgName", org_name) + ) + + locations = [] + for x in resources[key]: + y = {} + z = x.split(":") + y["reference"] = "Location/" + str(z[0]) + y["display"] = str(z[1]) + locations.append(y) + + obj = json.loads(rp) + obj["resource"]["location"] = locations + rp = json.dumps(obj) + + fp = fp + rp + "," + + fp = fp[:-1] + " ] } " + return fp + + +def check_resource(subject, entries, resource_type, url_filter): + if subject not in entries.keys(): + base_url = get_base_url() + check_url = ( + base_url + "/" + resource_type + "/_search?_count=1&" + url_filter + subject + ) + response = handle_request("GET", "", check_url) + json_response = json.loads(response[0]) + + entries[subject] = json_response + return entries + + +def update_practitioner_role(resource, organization_id, organization_name): + try: + resource["organization"]["reference"] = "Organization/" + organization_id + resource["organization"]["display"] = organization_name + except KeyError: + org = { + "organization": { + "reference": "Organization/" + organization_id, + "display": organization_name, + } + } + resource.update(org) + return resource + + +def update_list(resource, inventory_id, supply_date): + with open(json_path + "inventory_location_list_payload.json") as json_file: + payload_string = json_file.read() + + payload_string = payload_string.replace("$supply_date", supply_date).replace( + "$inventory_id", inventory_id + ) + json_payload = json.loads(payload_string) + + try: + entries = resource["entry"] + if inventory_id not in str(entries): + entry = json_payload["entry"][0] + entries.append(entry) + + except KeyError: + entry = {"entry": json_payload["entry"]} + resource.update(entry) + return resource + + +def create_new_practitioner_role( + new_id, practitioner_name, practitioner_id, organization_name, organization_id +): + with open(json_path + "practitioner_organization_payload.json") as json_file: + payload_string = json_file.read() + + payload_string = ( + payload_string.replace("$id", new_id) + .replace("$practitioner_id", practitioner_id) + .replace("$practitioner_name", practitioner_name) + .replace("$organization_id", organization_id) + .replace("$organization_name", organization_name) + ) + resource = json.loads(payload_string) + return resource + + +def create_new_list(new_id, location_id, inventory_id, title, supply_date): + with open(json_path + "inventory_location_list_payload.json") as json_file: + payload_string = json_file.read() + + payload_string = ( + payload_string.replace("$id", new_id) + .replace("$title", title) + .replace("$location_id", location_id) + .replace("$supply_date", supply_date) + .replace("$inventory_id", inventory_id) + ) + resource = json.loads(payload_string) + return resource + + +def build_assign_payload(rows, resource_type, url_filter): + bundle = {"resourceType": "Bundle", "type": "transaction", "entry": []} + + subject_id = item_id = organization_name = practitioner_name = inventory_name = ( + supply_date + ) = resource_id = version = "" + entries = {} + resource = {} + results = {} + + for row in rows: + if resource_type == "List": + # inventory_name, inventory_id, supply_date, location_id + inventory_name, item_id, supply_date, subject_id = row + if resource_type == "PractitionerRole": + # practitioner_name, practitioner_id, organization_name, organization_id + practitioner_name, subject_id, organization_name, item_id = row + + get_content = check_resource(subject_id, entries, resource_type, url_filter) + json_response = get_content[subject_id] + + if json_response["total"] == 1: + logging.info("Updating existing resource") + resource = json_response["entry"][0]["resource"] + + if resource_type == "PractitionerRole": + resource = update_practitioner_role( + resource, item_id, organization_name + ) + if resource_type == "List": + resource = update_list(resource, item_id, supply_date) + + if "meta" in resource: + version = resource["meta"]["versionId"] + resource_id = resource["id"] + del resource["meta"] + + elif json_response["total"] == 0: + logging.info("Creating a new resource") + resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, subject_id + item_id)) + + if resource_type == "PractitionerRole": + resource = create_new_practitioner_role( + resource_id, + practitioner_name, + subject_id, + organization_name, + item_id, + ) + if resource_type == "List": + resource = create_new_list( + resource_id, subject_id, item_id, inventory_name, supply_date + ) + version = "1" + + try: + resource["entry"] = ( + entries[subject_id]["resource"]["resource"]["entry"] + + resource["entry"] + ) + except KeyError: + logging.debug("No existing entries") + + else: + raise ValueError("The number of references should only be 0 or 1") + + payload = { + "request": { + "method": "PUT", + "url": resource_type + "/" + resource_id, + "ifMatch": version, + }, + "resource": resource, + } + entries[subject_id]["resource"] = payload + results[subject_id] = payload + + final_entries = [] + for entry in results: + final_entries.append(results[entry]) + + bundle["entry"] = final_entries + return json.dumps(bundle, indent=4) + + +def build_group_list_resource( + list_resource_id: str, csv_file: str, full_list_created_resources: list, title: str +): + if not list_resource_id: + list_resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, csv_file)) + current_version = get_resource(list_resource_id, "List") + method = "create" if current_version == str(0) else "update" + resource = [[title, "current", method, list_resource_id]] + result_payload = build_payload( + "List", resource, "json_payloads/group_list_payload.json" + ) + return process_resources_list(result_payload, full_list_created_resources) + + +# This function takes a 'created_resources' array and a response string +# It converts the response string to a json object, then loops through the entry array +# extracting all the referenced resources and adds them to the created_resources array +# then returns it +def extract_resources(created_resources, response_string): + json_response = json.loads(response_string) + entry = json_response["entry"] + for item in entry: + resource = item["response"]["location"] + index = resource.find("/", resource.find("/") + 1) + created_resources.append(resource[0:index]) + return created_resources + + +# This function takes a List resource payload and a list of resources +# It adds the resources into the List resource's entry array +# then returns the full resource payload +def process_resources_list(payload, resources_list): + entry = [] + for resource in resources_list: + item = {"item": {"reference": resource}} + entry.append(item) + + payload = json.loads(payload) + payload["entry"][0]["resource"]["entry"] = entry + return payload + + +def link_to_location(resource_list): + arr = [] + with click.progressbar( + resource_list, label="Progress::Linking inventory to location" + ) as link_locations_progress: + for resource in link_locations_progress: + try: + if resource[14]: + # name, inventory_id, supply_date, location_id + resource_link = [ + resource[0], + resource[3], + resource[9], + resource[14], + ] + arr.append(resource_link) + except IndexError: + logging.info("No location provided for " + resource[0]) + + if len(arr) > 0: + return build_assign_payload(arr, "List", "subject=Location/") + else: + return "" diff --git a/build/importer/services/__init__.py b/build/importer/importer/config/__init__.py similarity index 100% rename from build/importer/services/__init__.py rename to build/importer/importer/config/__init__.py diff --git a/build/importer/importer/config/settings.py b/build/importer/importer/config/settings.py new file mode 100644 index 0000000..b7a18e3 --- /dev/null +++ b/build/importer/importer/config/settings.py @@ -0,0 +1,51 @@ +import os + +from dotenv import load_dotenv + +from importer.services.fhir_keycloak_api import (ExternalAuthenticationOptions, + FhirKeycloakApi, + FhirKeycloakApiOptions, + InternalAuthenticationOptions) + +load_dotenv() + +client_id = os.getenv("client_id") +client_secret = os.getenv("client_secret") +fhir_base_url = os.getenv("fhir_base_url") +keycloak_url = os.getenv("keycloak_url") +realm = os.getenv("realm") + +product_access_token = os.getenv("product_access_token") + +username = os.getenv("username") +password = os.getenv("password") + +access_token = os.getenv("access_token") +refresh_token = os.getenv("refresh_token") + +authentication_options = None +if username is not None and password is not None: + authentication_options = InternalAuthenticationOptions( + client_id=client_id, + client_secret=client_secret, + keycloak_base_uri=keycloak_url, + realm=realm, + user_username=username, + user_password=password, + ) +elif access_token is not None and refresh_token is not None: + authentication_options = ExternalAuthenticationOptions( + client_id=client_id, + client_secret=client_secret, + keycloak_base_uri=keycloak_url, + realm=realm, + access_token=access_token, + refresh_token=refresh_token, + ) +else: + raise ValueError("Unable to get authentication parameters") + +api_service_options = FhirKeycloakApiOptions( + fhir_base_uri=fhir_base_url, authentication_options=authentication_options +) +api_service = FhirKeycloakApi(api_service_options) diff --git a/build/importer/importer/request.py b/build/importer/importer/request.py new file mode 100644 index 0000000..262d580 --- /dev/null +++ b/build/importer/importer/request.py @@ -0,0 +1,30 @@ +import logging + +from importer.config.settings import api_service + + +# This function makes the request to the provided url +# to create resources +def post_request(request_type, payload, url, json_payload): + logging.info("Posting request") + logging.info("Request type: " + request_type) + logging.info("Url: " + url) + logging.debug("Payload: " + payload) + + return api_service.request( + method=request_type, url=url, data=payload, json=json_payload + ) + + +def handle_request(request_type, payload, url, json_payload=None): + try: + response = post_request(request_type, payload, url, json_payload) + if response.status_code == 200 or response.status_code == 201: + logging.info("[" + str(response.status_code) + "]" + ": SUCCESS!") + + if request_type == "GET": + return response.text, response.status_code + else: + return response + except Exception as err: + logging.error(err) diff --git a/build/importer/utils/__init__.py b/build/importer/importer/services/__init__.py similarity index 100% rename from build/importer/utils/__init__.py rename to build/importer/importer/services/__init__.py diff --git a/build/importer/services/fhir_keycloak_api.py b/build/importer/importer/services/fhir_keycloak_api.py similarity index 58% rename from build/importer/services/fhir_keycloak_api.py rename to build/importer/importer/services/fhir_keycloak_api.py index 9e51ed2..e64a8e0 100644 --- a/build/importer/services/fhir_keycloak_api.py +++ b/build/importer/importer/services/fhir_keycloak_api.py @@ -6,26 +6,25 @@ 4. upload/update fhir resources """ -from dataclasses import dataclass, fields, field +import os +import time +from dataclasses import dataclass, field, fields from typing import Union +import backoff +import jwt import requests -# from keycloak import KeycloakOpenID, KeycloakOpenIDConnection +from oauthlib.oauth2 import LegacyApplicationClient from requests_oauthlib import OAuth2Session -from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient -from urllib.parse import urljoin -import time -import jwt -import backoff -from functools import wraps + +keycloak_url = os.getenv("keycloak_url") + def is_readable_string(s): """ Check if a variable is not an empty string. - Args: s (str): The variable to check. - Returns: bool: True if s is not an empty string, False otherwise. """ @@ -35,6 +34,7 @@ def is_readable_string(s): @dataclass class IamUri: """Keycloak authentication uris""" + keycloak_base_uri: str realm: str client_id: str @@ -42,32 +42,45 @@ class IamUri: token_uri: str = field(init=False) def __post_init__(self): - for field in fields(self): - if field.init and not is_readable_string(getattr(self, field.name)): - raise ValueError(f"{self.__class__.__name__} can only be initialized with str values") - self.token_uri = self.keycloak_base_uri + "/realms/" + self.realm + "/protocol/openid-connect/token" + for _field in fields(self): + if _field.init and not is_readable_string(getattr(self, _field.name)): + raise ValueError( + f"{self.__class__.__name__} can only be initialized with str values" + ) + self.token_uri = ( + self.keycloak_base_uri + + "/realms/" + + self.realm + + "/protocol/openid-connect/token" + ) self.keycloak_realm_uri = self.keycloak_base_uri + "/admin/realms/" + self.realm @dataclass class InternalAuthenticationOptions(IamUri): """Describes config options for authentication that we have to handle ourselves""" + user_username: str user_password: str + @dataclass class ExternalAuthenticationOptions(IamUri): - """Describes config options for authentication that we have to handle ourselves""" + """Describes config options for authentication that we don't have to handle ourselves""" + access_token: str refresh_token: str @dataclass class FhirKeycloakApiOptions: - authentication_options: Union[InternalAuthenticationOptions, ExternalAuthenticationOptions] + authentication_options: Union[ + InternalAuthenticationOptions, ExternalAuthenticationOptions + ] fhir_base_uri: str -class FhirKeycloakApi(): + +class FhirKeycloakApi: def __init__(self, options: FhirKeycloakApiOptions): auth_options = options.authentication_options if isinstance(auth_options, ExternalAuthenticationOptions): @@ -79,15 +92,14 @@ def __init__(self, options: FhirKeycloakApiOptions): self.auth_options = auth_options self.fhir_base_uri = options.fhir_base_uri - @backoff.on_exception(backoff.expo, requests.exceptions.RequestException, max_time=180) + @backoff.on_exception( + backoff.expo, requests.exceptions.RequestException, max_time=180 + ) def request(self, **kwargs): # TODO - spread headers into kwargs. - headers = { - "content-type": "application/json", - "accept": "application/json" - } + headers = {"content-type": "application/json", "accept": "application/json"} response = self.api_service.oauth.request(headers=headers, **kwargs) - if response.status_code == 401: + if response.status_code == 401 or '' in response.text: self.api_service.refresh_token() return self.api_service.oauth.request(headers=headers, **kwargs) return response @@ -104,18 +116,24 @@ def __init__(self, option: InternalAuthenticationOptions): def get_token(self): """ - Oauth 2 does not work without an ssl layer to test this locally see https://stackoverflow.com/a/27785830/14564571 + Oauth 2 does not work without a ssl layer to test this locally see https://stackoverflow.com/a/27785830/14564571 :return: """ - token = self.oauth.fetch_token(token_url=self.options.token_uri, client_id=self.options.client_id, - client_secret=self.options.client_secret, username=self.options.user_username, password=self.options.user_password) + token = self.oauth.fetch_token( + token_url=self.options.token_uri, + client_id=self.options.client_id, + client_secret=self.options.client_secret, + username=self.options.user_username, + password=self.options.user_password, + ) return token - def refresh_token(self,): + def refresh_token( + self, + ): return self.get_token() - def _is_refresh_required(self): # TODO some defensive programming would be nice. return time.time() > self.oauth.token.get("expires_at") @@ -126,37 +144,56 @@ def decode_token(self): # return json.loads(full_jwt.token.payload.decode("utf-8")) pass + class ExternalAuthenticationService: def __init__(self, option: ExternalAuthenticationOptions): self.options = option client = LegacyApplicationClient(client_id=self.options.client_id) - oauth = OAuth2Session(client=client, - token={"access_token": self.options.access_token, "refresh_token": self.options.refresh_token, 'token_type': "Bearer", "expires_in": 18000}, - auto_refresh_url=self.options.token_uri, - ) + oauth = OAuth2Session( + client=client, + token={ + "access_token": self.options.access_token, + "refresh_token": self.options.refresh_token, + "token_type": "Bearer", + "expires_in": 18000, + }, + auto_refresh_url=self.options.token_uri, + ) self.client = client self.oauth = oauth def get_token(self): """ - Oauth 2 does not work without an ssl layer to test this locally see https://stackoverflow.com/a/27785830/14564571 + Oauth 2 does not work without a ssl layer to test this locally see https://stackoverflow.com/a/27785830/14564571 :return: """ - # return the current token, not if its expired or invalid raise an irrecoverable show stopper error. + # return the current token, not if its expired or invalid raise an irrecoverable showstopper error. if self._is_refresh_required(): # if expired - self.oauth.refresh_token(token_url=self.options.token_uri, client_id=self.options.client_id, client_secret=self.options.client_secret) - - token = self.oauth.fetch_token(token_url=self.options.token_uri, client_id=self.options.client_id, - client_secret=self.options.client_secret) + self.oauth.refresh_token( + token_url=self.options.token_uri, + client_id=self.options.client_id, + client_secret=self.options.client_secret, + ) + + token = self.oauth.fetch_token( + token_url=self.options.token_uri, + client_id=self.options.client_id, + client_secret=self.options.client_secret, + ) return token else: return self.oauth.token - def refresh_token(self,): - return self.oauth.refresh_token(self.options.token_uri, client_id=self.options.client_id, client_secret=self.options.client_secret) - + def refresh_token( + self, + ): + return self.oauth.refresh_token( + self.options.token_uri, + client_id=self.options.client_id, + client_secret=self.options.client_secret, + ) def _is_refresh_required(self): # TODO some defensive programming would be nice. @@ -170,17 +207,14 @@ def _is_refresh_required(self): def decode_token(self, token: str): # TODO - verify JWT _algorithms = "HS256" - _do_verify=False - cert_uri = "http://localhost:8080/auth/realms/fhir/protocol/openid-connect/certs" + _do_verify = False + cert_uri = f"{keycloak_url}/realms/fhir/protocol/openid-connect/certs" res = self.oauth.get(cert_uri).json().get("keys") # tired first_key = res[0] jwk = jwt.jwk_from_dict(first_key) _algorithms = first_key.get("alg") instance = jwt.JWT() - return instance.decode(token, algorithms=_algorithms, key=jwk, do_verify=True, do_time_check=True) - - def handleRequest(self, **kwargs): - # self.oauth. - pass - + return instance.decode( + token, algorithms=_algorithms, key=jwk, do_verify=True, do_time_check=True + ) diff --git a/build/importer/importer/users.py b/build/importer/importer/users.py new file mode 100644 index 0000000..e34d3c8 --- /dev/null +++ b/build/importer/importer/users.py @@ -0,0 +1,380 @@ +import json +import logging +import pathlib +import uuid + +from importer.builder import get_base_url +from importer.config.settings import api_service, keycloak_url +from importer.request import handle_request + +dir_path = str(pathlib.Path(__file__).parent.resolve()) +json_path = "/".join([dir_path, "../json_payloads/"]) + + +def get_keycloak_url(): + return api_service.auth_options.keycloak_realm_uri + + +# This function builds the user payload and posts it to +# the keycloak api to create a new user +# it also adds the user to the provided keycloak group +# and sets the user password +def create_user(user): + ( + firstName, + lastName, + username, + email, + userId, + userType, + _, + keycloakGroupId, + keycloakGroupName, + appId, + password, + ) = user + + with open(json_path + "keycloak_user_payload.json") as json_file: + payload_string = json_file.read() + + obj = json.loads(payload_string) + obj["firstName"] = firstName + obj["lastName"] = lastName + obj["username"] = username + obj["email"] = email + obj["attributes"]["fhir_core_app_id"][0] = appId + + final_string = json.dumps(obj) + logging.info("Creating user: " + username) + _keycloak_url = get_keycloak_url() + r = handle_request("POST", final_string, _keycloak_url + "/users") + if r.status_code == 201: + logging.info("User created successfully") + new_user_location = r.headers["Location"] + user_id = (new_user_location.split("/"))[-1] + + # add user to group + payload = ( + '{"id": "' + keycloakGroupId + '", "name": "' + keycloakGroupName + '"}' + ) + group_endpoint = user_id + "/groups/" + keycloakGroupId + url = _keycloak_url + "/users/" + group_endpoint + logging.info("Adding user to Keycloak group: " + keycloakGroupName) + r = handle_request("PUT", payload, url) + + # set password + payload = '{"temporary":false,"type":"password","value":"' + password + '"}' + password_endpoint = user_id + "/reset-password" + url = _keycloak_url + "/users/" + password_endpoint + logging.info("Setting user password") + r = handle_request("PUT", payload, url) + + return user_id + else: + return 0 + + +# This function build the FHIR resources related to a +# new user and posts them to the FHIR api for creation +def create_user_resources(user_id, user): + logging.info("Creating user resources") + ( + firstName, + lastName, + username, + email, + id, + userType, + enableUser, + keycloakGroupId, + keycloakGroupName, + _, + password, + ) = user + + # generate uuids + if len(str(id).strip()) == 0: + practitioner_uuid = str( + uuid.uuid5( + uuid.NAMESPACE_DNS, username + keycloakGroupId + "practitioner_uuid" + ) + ) + else: + practitioner_uuid = id + + group_uuid = str( + uuid.uuid5(uuid.NAMESPACE_DNS, username + keycloakGroupId + "group_uuid") + ) + practitioner_role_uuid = str( + uuid.uuid5( + uuid.NAMESPACE_DNS, username + keycloakGroupId + "practitioner_role_uuid" + ) + ) + + # get payload and replace strings + initial_string = """{"resourceType": "Bundle","type": "transaction","meta": {"lastUpdated": ""},"entry": """ + with open(json_path + "user_resources_payload.json") as json_file: + payload_string = json_file.read() + + # replace the variables in payload + ff = ( + payload_string.replace("$practitioner_uuid", practitioner_uuid) + .replace("$keycloak_user_uuid", user_id) + .replace("$firstName", firstName) + .replace("$lastName", lastName) + .replace("$email", email) + .replace('"$enable_user"', enableUser) + .replace("$group_uuid", group_uuid) + .replace("$practitioner_role_uuid", practitioner_role_uuid) + ) + + obj = json.loads(ff) + + if userType.strip() == "Supervisor": + obj[2]["resource"]["code"] = { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "236321002", + "display": "Supervisor (occupation)", + } + ] + } + elif userType.strip() == "Practitioner": + obj[2]["resource"]["code"] = { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "405623001", + "display": "Assigned practitioner", + } + ] + } + else: + del obj[2]["resource"]["code"] + ff = json.dumps(obj, indent=4) + + payload = initial_string + ff + "}" + return payload + + +def confirm_keycloak_user(user): + # Confirm that the keycloak user details are as expected + user_username = str(user[2]).strip() + user_email = str(user[3]).strip() + _keycloak_url = get_keycloak_url() + response = handle_request( + "GET", "", _keycloak_url + "/users?exact=true&username=" + user_username + ) + logging.debug(response) + json_response = json.loads(response[0]) + + try: + response_email = json_response[0]["email"] + except IndexError: + response_email = "" + + try: + response_username = json_response[0]["username"] + except IndexError: + logging.error("Skipping user: " + str(user)) + logging.error("Username not found!") + return 0 + + if response_username != user_username: + logging.error("Skipping user: " + str(user)) + logging.error("Username does not match") + return 0 + + if len(response_email) > 0 and response_email != user_email: + logging.error("Email does not match for user: " + str(user)) + + keycloak_id = json_response[0]["id"] + logging.info("User confirmed with id: " + keycloak_id) + return keycloak_id + + +def confirm_practitioner(user, user_id): + practitioner_uuid = str(user[4]).strip() + base_url = get_base_url() + if not practitioner_uuid: + # If practitioner uuid not provided in csv, check if any practitioners exist linked to the keycloak user_id + r = handle_request("GET", "", base_url + "/Practitioner?identifier=" + user_id) + json_r = json.loads(r[0]) + counter = json_r["total"] + if counter > 0: + logging.info( + str(counter) + " Practitioner(s) exist, linked to the provided user" + ) + return True + else: + return False + + r = handle_request("GET", "", base_url + "/Practitioner/" + practitioner_uuid) + + if r[1] == 404: + logging.info("Practitioner does not exist, proceed to creation") + return False + else: + try: + json_r = json.loads(r[0]) + identifiers = json_r["identifier"] + keycloak_id = 0 + for id in identifiers: + if id["use"] == "secondary": + keycloak_id = id["value"] + + if str(keycloak_id) == user_id: + logging.info( + "The Keycloak user and Practitioner are linked as expected" + ) + return True + else: + logging.error( + "The Keycloak user and Practitioner are not linked as expected" + ) + return True + + except Exception as err: + logging.error("Error occurred trying to find Practitioner: " + str(err)) + return True + + +def create_roles(role_list, roles_max): + for role in role_list: + current_role = str(role[0]) + logging.debug("The current role is: " + current_role) + + # check if role already exists + role_response = handle_request( + "GET", "", keycloak_url + "/roles/" + current_role + ) + logging.debug(role_response) + if current_role in role_response[0]: + logging.error("A role already exists with the name " + current_role) + else: + role_payload = '{"name": "' + current_role + '"}' + create_role = handle_request("POST", role_payload, keycloak_url + "/roles") + if create_role.status_code == 201: + logging.info("Successfully created role: " + current_role) + + try: + # check if role has composite roles + if role[1]: + logging.debug("Role has composite roles") + # get roled id + full_role = handle_request( + "GET", "", keycloak_url + "/roles/" + current_role + ) + json_resp = json.loads(full_role[0]) + role_id = json_resp["id"] + logging.debug("roleId: " + str(role_id)) + + # get all available roles + available_roles = handle_request( + "GET", + "", + keycloak_url + + "/admin-ui-available-roles/roles/" + + role_id + + "?first=0&max=" + + str(roles_max) + + "&search=", + ) + json_roles = json.loads(available_roles[0]) + logging.debug("json_roles: " + str(json_roles)) + + rolesMap = {} + + for jrole in json_roles: + # remove client and clientId, then rename role to name + # to build correct payload + del jrole["client"] + del jrole["clientId"] + jrole["name"] = jrole["role"] + del jrole["role"] + rolesMap[str(jrole["name"])] = jrole + + associated_roles = str(role[2]) + logging.debug("Associated roles: " + associated_roles) + associated_role_array = associated_roles.split("|") + arr = [] + for arole in associated_role_array: + if arole in rolesMap.keys(): + arr.append(rolesMap[arole]) + else: + logging.error("Role " + arole + "does not exist") + + payload_arr = json.dumps(arr) + handle_request( + "POST", + payload_arr, + keycloak_url + "/roles-by-id/" + role_id + "/composites", + ) + + except IndexError: + pass + + +def get_group_id(group): + # check if group exists + all_groups = handle_request("GET", "", keycloak_url + "/groups") + json_groups = json.loads(all_groups[0]) + group_obj = {} + + for a_group in json_groups: + group_obj[a_group["name"]] = a_group + + if group in group_obj.keys(): + gid = str(group_obj[group]["id"]) + logging.info("Group already exists with id : " + gid) + return gid + + else: + logging.info("Group does not exists, lets create it") + # create the group + create_group_payload = '{"name":"' + group + '"}' + handle_request("POST", create_group_payload, keycloak_url + "/groups") + return get_group_id(group) + + +def assign_group_roles(role_list, group, roles_max): + group_id = get_group_id(group) + logging.debug("The groupID is: " + group_id) + + # get available roles + available_roles_for_group = handle_request( + "GET", + "", + keycloak_url + + "/groups/" + + group_id + + "/role-mappings/realm/available?first=0&max=" + + str(roles_max), + ) + json_roles = json.loads(available_roles_for_group[0]) + role_obj = {} + + for j in json_roles: + role_obj[j["name"]] = j + + assign_payload = [] + for r in role_list: + if r[0] in role_obj.keys(): + assign_payload.append(role_obj[r[0]]) + + json_assign_payload = json.dumps(assign_payload) + handle_request( + "POST", + json_assign_payload, + keycloak_url + "/groups/" + group_id + "/role-mappings/realm", + ) + + +def assign_default_groups_roles(roles_max): + DEFAULT_GROUPS = { + "ANDROID_PRACTITIONER": ["ANDROID_CLIENT"], + "WEB_PRACTITIONER": ["WEB_CLIENT"], + } + for group_name, roles in DEFAULT_GROUPS.items(): + assign_group_roles(roles, group_name, roles_max) diff --git a/build/importer/importer/utils.py b/build/importer/importer/utils.py new file mode 100644 index 0000000..9a93c86 --- /dev/null +++ b/build/importer/importer/utils.py @@ -0,0 +1,393 @@ +import csv +import json +import logging +import os +import uuid +from datetime import datetime + +import click + +from importer.builder import get_base_url +from importer.config.settings import fhir_base_url, keycloak_url +from importer.request import post_request + + +# This function takes in a csv file +# reads it and returns a list of strings/lines +# It ignores the first line (assumes headers) +def read_csv(csv_file: str): + logging.info("Reading csv file") + with open(csv_file, mode="r") as file: + records = csv.reader(file, delimiter=",") + try: + next(records) + all_records = [] + + with click.progressbar( + records, label="Progress::Reading csv " + ) as read_csv_progress: + for record in read_csv_progress: + all_records.append(record) + + logging.info("Returning records from csv file") + return all_records + + except StopIteration: + logging.error("Stop iteration on empty file") + + +# Create a csv file and initialize the CSV writer +def write_csv(data, resource_type, fieldnames): + logging.info("Writing to csv file") + path = "csv/exports" + if not os.path.exists(path): + os.makedirs(path) + + current_time = datetime.now().strftime("%Y-%m-%d-%H-%M") + csv_file = f"{path}/{current_time}-export_{resource_type}.csv" + with open(csv_file, "w", newline="") as file: + csv_writer = csv.writer(file) + csv_writer.writerow(fieldnames) + with click.progressbar( + data, label="Progress:: Writing csv" + ) as write_csv_progress: + for row in write_csv_progress: + csv_writer.writerow(row) + return csv_file + + +def handle_request(request_type, payload, url, json_payload=None): + try: + response = post_request(request_type, payload, url, json_payload) + if response.status_code == 200 or response.status_code == 201: + logging.info("[" + str(response.status_code) + "]" + ": SUCCESS!") + + if request_type == "GET": + return response.text, response.status_code + else: + return response + except Exception as err: + logging.error(err) + + +# This function exports resources from the API to a csv file +def export_resources_to_csv(resource_type, parameter, value, limit): + base_url = get_base_url() + resource_url = "/".join([str(base_url), resource_type]) + if len(parameter) > 0: + resource_url = ( + resource_url + "?" + parameter + "=" + value + "&_count=" + str(limit) + ) + response = handle_request("GET", "", resource_url) + if response[1] == 200: + resources = json.loads(response[0]) + data = [] + try: + if resources["entry"]: + if resource_type == "Location": + elements = [ + "name", + "status", + "method", + "id", + "identifier", + "parentName", + "parentID", + "type", + "typeCode", + "physicalType", + "physicalTypeCode", + ] + elif resource_type == "Organization": + elements = ["name", "active", "method", "id", "identifier"] + elif resource_type == "CareTeam": + elements = [ + "name", + "status", + "method", + "id", + "identifier", + "organizations", + "participants", + ] + else: + elements = [] + with click.progressbar( + resources["entry"], label="Progress:: Extracting resource" + ) as extract_resources_progress: + for x in extract_resources_progress: + rl = [] + orgs_list = [] + participants_list = [] + for element in elements: + try: + if element == "method": + value = "update" + elif element == "active": + value = x["resource"]["active"] + elif element == "identifier": + value = x["resource"]["identifier"][0]["value"] + elif element == "organizations": + organizations = x["resource"][ + "managingOrganization" + ] + for index, value in enumerate(organizations): + reference = x["resource"][ + "managingOrganization" + ][index]["reference"] + new_reference = reference.split("/", 1)[1] + display = x["resource"]["managingOrganization"][ + index + ]["display"] + organization = ":".join( + [new_reference, display] + ) + orgs_list.append(organization) + string = "|".join(map(str, orgs_list)) + value = string + elif element == "participants": + participants = x["resource"]["participant"] + for index, value in enumerate(participants): + reference = x["resource"]["participant"][index][ + "member" + ]["reference"] + new_reference = reference.split("/", 1)[1] + display = x["resource"]["participant"][index][ + "member" + ]["display"] + participant = ":".join([new_reference, display]) + participants_list.append(participant) + string = "|".join(map(str, participants_list)) + value = string + elif element == "parentName": + value = x["resource"]["partOf"]["display"] + elif element == "parentID": + reference = x["resource"]["partOf"]["reference"] + value = reference.split("/", 1)[1] + elif element == "type": + value = x["resource"]["type"][0]["coding"][0][ + "display" + ] + elif element == "typeCode": + value = x["resource"]["type"][0]["coding"][0][ + "code" + ] + elif element == "physicalType": + value = x["resource"]["physicalType"]["coding"][0][ + "display" + ] + elif element == "physicalTypeCode": + value = x["resource"]["physicalType"]["coding"][0][ + "code" + ] + else: + value = x["resource"][element] + except KeyError: + value = "" + rl.append(value) + data.append(rl) + write_csv(data, resource_type, elements) + logging.info("Successfully written to csv") + else: + logging.info("No entry found") + except KeyError: + logging.info("No Resources Found") + else: + logging.error( + f"Failed to retrieve resource. Status code: {response[1]} response: {response[0]}" + ) + + +def build_mapped_payloads(resource_mapping, json_file, resources_count): + with open(json_file, "r") as file: + data_dict = json.load(file) + with click.progressbar( + resource_mapping, label="Progress::Setting up ... " + ) as resource_mapping_progress: + for resource_type in resource_mapping_progress: + index_positions = resource_mapping[resource_type] + resource_list = [data_dict[i] for i in index_positions] + set_resource_list(None, resource_list, resource_type, resources_count) + + +def build_resource_type_map(resources: str, mapping: dict, index_tracker: int = 0): + resource_list = json.loads(resources) + for index, resource in enumerate(resource_list): + resource_type = resource["resourceType"] + if resource_type in mapping.keys(): + mapping[resource_type].append(index + index_tracker) + else: + mapping[resource_type] = [index + index_tracker] + + global import_counter + import_counter = len(resource_list) + import_counter + + +def split_chunk( + chunk: str, + left_over_chunk: str, + size: int, + mapping: dict = None, + sync: str = None, + import_counter: int = 0, +): + if len(chunk) + len(left_over_chunk) < int(size): + # load can fit in one chunk, so remove closing bracket + last_bracket = chunk.rfind("}") + current_chunk = chunk[: int(last_bracket)] + next_left_over_chunk = "-" + if len(chunk.strip()) == 0: + last_bracket = left_over_chunk.rfind("}") + left_over_chunk = left_over_chunk[: int(last_bracket)] + else: + # load can't fit, so split on last full resource + split_index = chunk.rfind( + '},{"id"' + ) # Assumption that this string will find the last full resource + current_chunk = chunk[:split_index] + next_left_over_chunk = chunk[int(split_index) + 2 :] + if len(chunk.strip()) == 0: + last_bracket = left_over_chunk.rfind("}") + left_over_chunk = left_over_chunk[: int(last_bracket)] + + if len(left_over_chunk.strip()) == 0: + current_chunk = current_chunk[1:] + + chunk_list = "[" + left_over_chunk + current_chunk + "}]" + + if sync.lower() == "direct": + set_resource_list(chunk_list) + if sync.lower() == "sort": + build_resource_type_map(chunk_list, mapping, import_counter) + return next_left_over_chunk + + +def read_file_in_chunks(json_file: str, chunk_size: int, sync: str): + logging.info("Reading file in chunks ...") + incomplete_load = "" + mapping = {} + global import_counter + import_counter = 0 + with open(json_file, "r") as file: + while True: + chunk = file.read(chunk_size) + if not chunk: + break + incomplete_load = split_chunk( + chunk, incomplete_load, chunk_size, mapping, sync, import_counter + ) + return mapping + + +def set_resource_list( + objs: str = None, + json_list: list = None, + resource_type: str = None, + number_of_resources: int = 100, +): + if objs: + resources_array = json.loads(objs) + process_chunk(resources_array, resource_type) + if json_list: + if len(json_list) > number_of_resources: + for i in range(0, len(json_list), number_of_resources): + sub_list = json_list[i : i + number_of_resources] + process_chunk(sub_list, resource_type) + else: + process_chunk(json_list, resource_type) + + +def process_chunk(resources_array: list, resource_type: str): + new_arr = [] + with click.progressbar( + resources_array, label="Progress::Processing chunks ... " + ) as resources_array_progress: + for resource in resources_array_progress: + if not resource_type: + resource_type = resource["resourceType"] + try: + resource_id = resource["id"] + except KeyError: + if "identifier" in resource: + resource_identifier = resource["identifier"][0]["value"] + resource_id = str( + uuid.uuid5(uuid.NAMESPACE_DNS, resource_identifier) + ) + else: + resource_id = str(uuid.uuid4()) + + item = {"resource": resource, "request": {}} + item["request"]["method"] = "PUT" + item["request"]["url"] = "/".join([resource_type, resource_id]) + new_arr.append(item) + + json_payload = {"resourceType": "Bundle", "type": "transaction", "entry": new_arr} + + r = handle_request("POST", "", fhir_base_url, json_payload) + logging.info(r.text) + # TODO handle failures + + +def clean_duplicates(users, cascade_delete): + for user in users: + # get keycloak user uuid + username = str(user[2].strip()) + user_details = handle_request( + "GET", "", keycloak_url + "/users?exact=true&username=" + username + ) + obj = json.loads(user_details[0]) + keycloak_uuid = obj[0]["id"] + + # get Practitioner(s) + r = handle_request( + "GET", + "", + fhir_base_url + "/Practitioner?identifier=" + keycloak_uuid, + ) + practitioner_details = json.loads(r[0]) + count = practitioner_details["total"] + + try: + practitioner_uuid_provided = str(user[4].strip()) + except IndexError: + practitioner_uuid_provided = None + + if practitioner_uuid_provided: + if count == 1: + practitioner_uuid_returned = practitioner_details["entry"][0][ + "resource" + ]["id"] + # confirm the uuid matches the one provided in csv + if practitioner_uuid_returned == practitioner_uuid_provided: + logging.info("User " + username + " ok!") + else: + logging.error( + "User " + + username + + "has 1 Practitioner but it does not match the provided uuid" + ) + elif count > 1: + for x in practitioner_details["entry"]: + p_uuid = x["resource"]["id"] + if practitioner_uuid_provided == p_uuid: + # This is the correct resource, so skip it + continue + else: + logging.info( + "Deleting practitioner resource with uuid: " + str(p_uuid) + ) + delete_resource("Practitioner", p_uuid, cascade_delete) + else: + # count is less than 1 + logging.info("No Practitioners found") + + +def delete_resource(resource_type, resource_id, cascade): + if cascade: + cascade = "?_cascade=delete" + else: + cascade = "" + + resource_url = "/".join([fhir_base_url, resource_type, resource_id + cascade]) + r = handle_request("DELETE", "", resource_url) + logging.info(r.text) diff --git a/build/importer/json_payloads/group_list_payload.json b/build/importer/json_payloads/group_list_payload.json new file mode 100644 index 0000000..2315d54 --- /dev/null +++ b/build/importer/json_payloads/group_list_payload.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "PUT", + "url": "List/$unique_uuid", + "ifMatch": "$version" + }, + "resource": { + "resourceType": "List", + "id": "$unique_uuid", + "identifier": [ + { + "use": "official", + "value": "$identifier_uuid" + } + ], + "status": "$status", + "mode": "working", + "title": "$name", + "code": { + "coding": [ + { + "system": "http://smartregister.org/codes", + "code": "22138876", + "display": "Supply Inventory List" + } + ], + "text": "Supply Inventory List" + }, + "entry": [] + } +} diff --git a/build/importer/json_payloads/locations_payload.json b/build/importer/json_payloads/locations_payload.json index 12bd5a9..5c0fec3 100644 --- a/build/importer/json_payloads/locations_payload.json +++ b/build/importer/json_payloads/locations_payload.json @@ -1,62 +1,65 @@ { - "request": { - "method": "PUT", - "url": "Location/$unique_uuid", - "ifMatch" : "$version" + "request": { + "method": "PUT", + "url": "Location/$unique_uuid", + "ifMatch": "$version" + }, + "resource": { + "resourceType": "Location", + "id": "$unique_uuid", + "status": "$status", + "name": "$name", + "identifier": [ + { + "use": "official", + "value": "$unique_uuid" + } + ], + "partOf": { + "reference": "Location/$parentID", + "display": "$parentName" }, - "resource": { - "resourceType": "Location", - "id": "$unique_uuid", - "status": "$status", - "name": "$name", - "identifier": [ - { - "use": "official", - "value": "$unique_uuid" - } - ], - "partOf": { - "reference": "Location/$parentID", - "display": "$parentName" - }, - "type": [ - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/location-type", - "code": "$t_code", - "display": "$t_display" - } - ] - }, - { - "coding": [ - { - "system": "https://smartregister.org/codes/administrative-level", - "code": "$adminLevelCode", - "display": "Level $adminLevelCode" - } - ] - }, - { - "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", - "code": "$pt_code", - "display": "$pt_display" - } - ], - "physicalType": { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", - "code": "$pt_code", - "display": "$pt_display" - } - ] - }, - "position": { - "longitude": "$longitude", - "latitude": "$latitude" + "type": [ + { + "coding": [ + { + "system": "$t_system", + "code": "$t_code", + "display": "$t_display" + } + ] + }, + { + "coding": [ + { + "system": "https://smartregister.org/codes/administrative-level", + "code": "$adminLevelCode", + "display": "Level $adminLevelCode" + } + ] + }, + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "$pt_code", + "display": "$pt_display" + } + ] + } + ], + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "$pt_code", + "display": "$pt_display" } + ] + }, + "position": { + "longitude": "$longitude", + "latitude": "$latitude" } + } } - diff --git a/build/importer/json_payloads/product_group_payload.json b/build/importer/json_payloads/product_group_payload.json index a41d494..d6a8a20 100644 --- a/build/importer/json_payloads/product_group_payload.json +++ b/build/importer/json_payloads/product_group_payload.json @@ -2,22 +2,22 @@ "request": { "method": "PUT", "url": "Group/$unique_uuid", - "ifMatch" : "$version" + "ifMatch": "$version" }, "resource": { "resourceType": "Group", "id": "$unique_uuid", "identifier": [ { - "type":{ + "type": { "coding": { - "system" : "http://smartregister.org/codes", - "code" : "MATNUM" , + "system": "http://smartregister.org/codes", + "code": "MATNUM", "display": "Material Number" } }, "use": "official", - "value": "$unique_uuid" + "value": "$material_number" }, { "use": "secondary", @@ -142,4 +142,4 @@ } ] } -} \ No newline at end of file +} diff --git a/build/importer/main.py b/build/importer/main.py index 1c0208f..eba4fcc 100644 --- a/build/importer/main.py +++ b/build/importer/main.py @@ -1,1698 +1,23 @@ -import os -import csv -import json -import uuid -import click -import requests import logging import logging.config -import base64 -import magic +import pathlib from datetime import datetime -from config.settings import api_service, keycloak_url, fhir_base_url, product_access_token -from utils.location_process import process_locations - -# try: -# import config -# except ModuleNotFoundError: -# logging.error("The config.py file is missing!") -# exit() - -# global_access_token = "" - - -# This function takes in a csv file -# reads it and returns a list of strings/lines -# It ignores the first line (assumes headers) -def read_csv(csv_file): - logging.info("Reading csv file") - with open(csv_file, mode="r") as file: - records = csv.reader(file, delimiter=",") - try: - next(records) - all_records = [] - - with click.progressbar( - records, label="Progress::Reading csv " - ) as read_csv_progress: - for record in read_csv_progress: - all_records.append(record) - - logging.info("Returning records from csv file") - return all_records - - except StopIteration: - logging.error("Stop iteration on empty file") - - -# def get_access_token(): -# access_token = "" -# if global_access_token: -# return global_access_token -# -# try: -# if config.access_token: -# # get access token from config file -# access_token = config.access_token -# except AttributeError: -# logging.debug("No access token provided, trying to use client credentials") -# -# if not access_token: -# # get client credentials from config file -# client_id = config.client_id -# client_secret = config.client_secret -# username = config.username -# password = config.password -# access_token_url = config.access_token_url -# -# oauth = OAuth2Session(client=LegacyApplicationClient(client_id=client_id)) -# token = oauth.fetch_token( -# token_url=access_token_url, -# username=username, -# password=password, -# client_id=client_id, -# client_secret=client_secret, -# ) -# access_token = token["access_token"] -# -# return access_token - - - -def post_request(request_type, payload, url, json_payload): - logging.info("Posting request") - logging.info("Request type: " + request_type) - logging.info("Url: " + url) - logging.debug("Payload: " + payload) - return api_service.request(method=request_type, url=url, - data=payload, json=json_payload) - -def handle_request(request_type, payload, url, json_payload=None): - try: - response = post_request(request_type, payload, url, json_payload) - if response.status_code == 200 or response.status_code == 201: - logging.info("[" + str(response.status_code) + "]" + ": SUCCESS!") - - if request_type == "GET": - return response.text, response.status_code - else: - return response - except Exception as err: - logging.error(err) - - -def get_keycloak_url(): - return api_service.auth_options.keycloak_realm_uri - - -# This function builds the user payload and posts it to -# the keycloak api to create a new user -# it also adds the user to the provided keycloak group -# and sets the user password -def create_user(user): - ( - firstName, - lastName, - username, - email, - userId, - userType, - _, - keycloakGroupId, - keycloakGroupName, - appId, - password, - ) = user - - # TODO - move this out so that its not recreated for every user. - with open("json_payloads/keycloak_user_payload.json") as json_file: - payload_string = json_file.read() - - obj = json.loads(payload_string) - obj["firstName"] = firstName - obj["lastName"] = lastName - obj["username"] = username - obj["email"] = email - obj["attributes"]["fhir_core_app_id"][0] = appId - - final_string = json.dumps(obj) - logging.info("Creating user: " + username) - keycloak_url = get_keycloak_url() - r = handle_request("POST", final_string, keycloak_url + "/users") - if r.status_code == 201: - logging.info("User created successfully") - new_user_location = r.headers["Location"] - user_id = (new_user_location.split("/"))[-1] - - # add user to group - payload = ( - '{"id": "' + keycloakGroupId + '", "name": "' + keycloakGroupName + '"}' - ) - group_endpoint = user_id + "/groups/" + keycloakGroupId - url = keycloak_url + "/users/" + group_endpoint - logging.info("Adding user to Keycloak group: " + keycloakGroupName) - r = handle_request("PUT", payload, url) - - # set password - payload = '{"temporary":false,"type":"password","value":"' + password + '"}' - password_endpoint = user_id + "/reset-password" - url = keycloak_url + "/users/" + password_endpoint - logging.info("Setting user password") - r = handle_request("PUT", payload, url) - - return user_id - else: - return 0 - - -# This function build the FHIR resources related to a -# new user and posts them to the FHIR api for creation -def create_user_resources(user_id, user): - logging.info("Creating user resources") - ( - firstName, - lastName, - username, - email, - id, - userType, - enableUser, - keycloakGroupId, - keycloakGroupName, - _, - password, - ) = user - - # generate uuids - if len(str(id).strip()) == 0: - practitioner_uuid = str( - uuid.uuid5( - uuid.NAMESPACE_DNS, username + keycloakGroupId + "practitioner_uuid" - ) - ) - else: - practitioner_uuid = id - - group_uuid = str( - uuid.uuid5(uuid.NAMESPACE_DNS, username + keycloakGroupId + "group_uuid") - ) - practitioner_role_uuid = str( - uuid.uuid5( - uuid.NAMESPACE_DNS, username + keycloakGroupId + "practitioner_role_uuid" - ) - ) - - # get payload and replace strings - initial_string = """{"resourceType": "Bundle","type": "transaction","meta": {"lastUpdated": ""},"entry": """ - with open("json_payloads/user_resources_payload.json") as json_file: - payload_string = json_file.read() - - # replace the variables in payload - ff = ( - payload_string.replace("$practitioner_uuid", practitioner_uuid) - .replace("$keycloak_user_uuid", user_id) - .replace("$firstName", firstName) - .replace("$lastName", lastName) - .replace("$email", email) - .replace('"$enable_user"', enableUser) - .replace("$group_uuid", group_uuid) - .replace("$practitioner_role_uuid", practitioner_role_uuid) - ) - - obj = json.loads(ff) - - if userType.strip() == "Supervisor": - obj[2]["resource"]["code"] = { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": "236321002", - "display": "Supervisor (occupation)", - } - ] - } - elif userType.strip() == "Practitioner": - obj[2]["resource"]["code"] = { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": "405623001", - "display": "Assigned practitioner", - } - ] - } - else: - del obj[2]["resource"]["code"] - ff = json.dumps(obj, indent=4) - - payload = initial_string + ff + "}" - return payload - - -# custom extras for organizations -def organization_extras(resource, payload_string): - try: - _, orgActive, *_ = resource - except ValueError: - orgActive = "true" - - try: - payload_string = payload_string.replace("$active", orgActive) - except IndexError: - payload_string = payload_string.replace("$active", "true") - return payload_string - - -def identify_coding_object_index(array, current_system): - for index, value in enumerate(array): - list_of_systems = value["coding"][0]["system"] - if current_system in list_of_systems: - return index - - -def check_parent_admin_level(locationParentId): - base_url = get_base_url() - resource_url = "/".join([base_url, "Location", locationParentId]) - response = handle_request("GET", "", resource_url) - obj = json.loads(response[0]) - if "type" in obj: - response_type = obj["type"] - current_system = "administrative-level" - if current_system: - index = identify_coding_object_index(response_type, current_system) - if index >= 0: - code = obj["type"][index]["coding"][0]["code"] - admin_level = str(int(code) + 1) - return admin_level - else: - return None - else: - return None - - -# custom extras for locations -def location_extras(resource, payload_string): - try: - ( - locationName, - *_, - locationParentName, - locationParentId, - locationType, - locationTypeCode, - locationAdminLevel, - locationPhysicalType, - locationPhysicalTypeCode, - longitude, - latitude, - ) = resource - except ValueError: - locationParentName = "parentName" - locationParentId = "ParentId" - locationType = "type" - locationTypeCode = "typeCode" - locationAdminLevel = "adminLevel" - locationPhysicalType = "physicalType" - locationPhysicalTypeCode = "physicalTypeCode" - longitude = "longitude" - - try: - if locationParentName and locationParentName != "parentName": - payload_string = payload_string.replace( - "$parentName", locationParentName - ).replace("$parentID", locationParentId) - else: - obj = json.loads(payload_string) - del obj["resource"]["partOf"] - payload_string = json.dumps(obj, indent=4) - except IndexError: - obj = json.loads(payload_string) - del obj["resource"]["partOf"] - payload_string = json.dumps(obj, indent=4) - - try: - if len(locationType.strip()) > 0 and locationType != "type": - payload_string = payload_string.replace("$t_display", locationType) - if len(locationTypeCode.strip()) > 0 and locationTypeCode != "typeCode": - payload_string = payload_string.replace("$t_code", locationTypeCode) - else: - obj = json.loads(payload_string) - payload_type = obj["resource"]["type"] - current_system = "location-type" - index = identify_coding_object_index(payload_type, current_system) - if index >= 0: - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - except IndexError: - obj = json.loads(payload_string) - payload_type = obj["resource"]["type"] - current_system = "location-type" - index = identify_coding_object_index(payload_type, current_system) - if index >= 0: - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - - try: - if len(locationAdminLevel.strip()) > 0 and locationAdminLevel != "adminLevel": - payload_string = payload_string.replace( - "$adminLevelCode", locationAdminLevel - ) - else: - if locationAdminLevel in resource: - admin_level = check_parent_admin_level(locationParentId) - if admin_level: - payload_string = payload_string.replace( - "$adminLevelCode", admin_level - ) - else: - obj = json.loads(payload_string) - obj_type = obj["resource"]["type"] - current_system = "administrative-level" - index = identify_coding_object_index(obj_type, current_system) - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - else: - obj = json.loads(payload_string) - obj_type = obj["resource"]["type"] - current_system = "administrative-level" - index = identify_coding_object_index(obj_type, current_system) - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - except IndexError: - if locationAdminLevel in resource: - admin_level = check_parent_admin_level(locationParentId) - if admin_level: - payload_string = payload_string.replace("$adminLevelCode", admin_level) - else: - obj = json.loads(payload_string) - obj_type = obj["resource"]["type"] - current_system = "administrative-level" - index = identify_coding_object_index(obj_type, current_system) - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - else: - obj = json.loads(payload_string) - obj_type = obj["resource"]["type"] - current_system = "administrative-level" - index = identify_coding_object_index(obj_type, current_system) - del obj["resource"]["type"][index] - payload_string = json.dumps(obj, indent=4) - - try: - if ( - len(locationPhysicalType.strip()) > 0 - and locationPhysicalType != "physicalType" - ): - payload_string = payload_string.replace("$pt_display", locationPhysicalType) - if ( - len(locationPhysicalTypeCode.strip()) > 0 - and locationPhysicalTypeCode != "physicalTypeCode" - ): - payload_string = payload_string.replace( - "$pt_code", locationPhysicalTypeCode - ) - else: - obj = json.loads(payload_string) - del obj["resource"]["physicalType"] - payload_string = json.dumps(obj, indent=4) - except IndexError: - obj = json.loads(payload_string) - del obj["resource"]["physicalType"] - payload_string = json.dumps(obj, indent=4) - - try: - if longitude and longitude != "longitude": - payload_string = payload_string.replace('"$longitude"', longitude).replace( - '"$latitude"', latitude - ) - else: - obj = json.loads(payload_string) - del obj["resource"]["position"] - payload_string = json.dumps(obj, indent=4) - except IndexError: - obj = json.loads(payload_string) - del obj["resource"]["position"] - payload_string = json.dumps(obj, indent=4) - - return payload_string - - -# custom extras for careTeams -def care_team_extras(resource, payload_string, ftype): - orgs_list = [] - participant_list = [] - elements = [] - elements2 = [] - - try: - *_, organizations, participants = resource - except ValueError: - organizations = "organizations" - participants = "participants" - - if organizations and organizations != "organizations": - elements = organizations.split("|") - else: - logging.info("No organizations") - - if participants and participants != "participants": - elements2 = participants.split("|") - else: - logging.info("No participants") - - if "orgs" in ftype: - for org in elements: - y = {} - x = org.split(":") - y["reference"] = "Organization/" + str(x[0]) - y["display"] = str(x[1]) - orgs_list.append(y) - - z = { - "role": [ - { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": "394730007", - "display": "Healthcare related organization", - } - ] - } - ], - "member": {}, - } - z["member"]["reference"] = "Organization/" + str(x[0]) - z["member"]["display"] = str(x[1]) - participant_list.append(z) - - if len(participant_list) > 0: - obj = json.loads(payload_string) - obj["resource"]["participant"] = participant_list - obj["resource"]["managingOrganization"] = orgs_list - payload_string = json.dumps(obj) - - if "users" in ftype: - if len(elements2) > 0: - elements = elements2 - for user in elements: - y = {"member": {}} - x = user.split(":") - y["member"]["reference"] = "Practitioner/" + str(x[0]) - y["member"]["display"] = str(x[1]) - participant_list.append(y) - - if len(participant_list) > 0: - obj = json.loads(payload_string) - obj["resource"]["participant"] = participant_list - payload_string = json.dumps(obj) - - return payload_string - - -# custom extras for product import -def group_extras(resource, payload_string, group_type): - payload_obj = json.loads(payload_string) - item_name = resource[0] - del_indexes = [] - - GROUP_INDEX_MAPPING = { - "product_secondary_id_index": 1, - "product_is_attractive_index": 0, - "product_is_available_index": 1, - "product_condition_index": 2, - "product_appropriate_usage_index": 3, - "product_accountability_period_index": 4, - "product_image_index": 5, - "inventory_official_id_index": 0, - "inventory_secondary_id_index": 1, - "inventory_usual_id_index": 2, - "inventory_member_index": 0, - "inventory_quantity_index": 0, - "inventory_unicef_section_index": 1, - "inventory_donor_index": 2, - } - - if group_type == "product": - ( - _, - active, - *_, - previous_id, - is_attractive_item, - availability, - condition, - appropriate_usage, - accountability_period, - image_source_url, - ) = resource - - if active: - payload_obj["resource"]["active"] = active - else: - del payload_obj["resource"]["active"] - - if previous_id: - payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["product_secondary_id_index"] - ]["value"] = previous_id - else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["product_secondary_id_index"] - ] - - if is_attractive_item: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_is_attractive_index"] - ]["valueBoolean"] = is_attractive_item - else: - del_indexes.append(GROUP_INDEX_MAPPING["product_is_attractive_index"]) - - if availability: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_is_available_index"] - ]["valueCodeableConcept"]["text"] = availability - else: - del_indexes.append(GROUP_INDEX_MAPPING["product_is_available_index"]) - - if condition: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_condition_index"] - ]["valueCodeableConcept"]["text"] = condition - else: - del_indexes.append(GROUP_INDEX_MAPPING["product_condition_index"]) - - if appropriate_usage: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_appropriate_usage_index"] - ]["valueCodeableConcept"]["text"] = appropriate_usage - else: - del_indexes.append(GROUP_INDEX_MAPPING["product_appropriate_usage_index"]) - - if accountability_period: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_accountability_period_index"] - ]["valueQuantity"]["value"] = accountability_period - else: - del_indexes.append( - GROUP_INDEX_MAPPING["product_accountability_period_index"] - ) - - if image_source_url: - image_binary = save_image(image_source_url) - if image_binary != 0: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["product_image_index"] - ]["valueReference"]["reference"] = ("Binary/" + image_binary) - else: - logging.error( - "Unable to link the image Binary resource for product " + item_name - ) - del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) - else: - del_indexes.append(GROUP_INDEX_MAPPING["product_image_index"]) - - elif group_type == "inventory": - ( - _, - active, - *_, - po_number, - serial_number, - usual_id, - actual, - product_id, - delivery_date, - accountability_date, - quantity, - unicef_section, - donor, - location, - ) = resource - - if active: - payload_obj["resource"]["active"] = bool(active) - else: - del payload_obj["resource"]["active"] - - if serial_number: - payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_official_id_index"] - ]["value"] = serial_number - else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_official_id_index"] - ] - - if po_number: - payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_secondary_id_index"] - ]["value"] = po_number - else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_secondary_id_index"] - ] - - if usual_id: - payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_usual_id_index"] - ]["value"] = usual_id - else: - del payload_obj["resource"]["identifier"][ - GROUP_INDEX_MAPPING["inventory_usual_id_index"] - ] - - if actual: - payload_obj["resource"]["actual"] = bool(actual) - else: - del payload_obj["resource"]["actual"] - - if product_id: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["entity"]["reference"] = ("Group/" + product_id) - else: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["entity"]["reference"] = "Group/" - - if delivery_date: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["period"]["start"] = delivery_date - else: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["period"]["start"] = "" - - if accountability_date: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["period"]["end"] = accountability_date - else: - payload_obj["resource"]["member"][ - GROUP_INDEX_MAPPING["inventory_member_index"] - ]["period"]["end"] = "" - - if quantity: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["inventory_quantity_index"] - ]["valueQuantity"]["value"] = int(quantity) - else: - del_indexes.append(GROUP_INDEX_MAPPING["inventory_quantity_index"]) - - if unicef_section: - payload_obj["resource"]["characteristic"][ - GROUP_INDEX_MAPPING["inventory_unicef_section_index"] - ]["valueCodeableConcept"]["text"] = unicef_section - else: - del_indexes.append(GROUP_INDEX_MAPPING["inventory_unicef_section_index"]) - - if donor: - payload_obj["resource"]["characteristic"][2]["valueCodeableConcept"][ - "text" - ] = donor - else: - del_indexes.append(GROUP_INDEX_MAPPING["inventory_donor_index"]) - - else: - logging.info("Group type not defined") - - for x in reversed(del_indexes): - del payload_obj["resource"]["characteristic"][x] - - payload_string = json.dumps(payload_obj, indent=4) - return payload_string - - -def extract_matches(resource_list): - teamMap = {} - with click.progressbar( - resource_list, label="Progress::Extract matches " - ) as extract_progress: - for resource in extract_progress: - group_name, group_id, item_name, item_id = resource - if group_id.strip() and item_id.strip(): - if group_id not in teamMap.keys(): - teamMap[group_id] = [item_id + ":" + item_name] - else: - teamMap[group_id].append(item_id + ":" + item_name) - else: - logging.error("Missing required id: Skipping " + str(resource)) - return teamMap - - -def update_practitioner_role(resource, organization_id, organization_name): - try: - resource["organization"]["reference"] = "Organization/" + organization_id - resource["organization"]["display"] = organization_name - except KeyError: - org = { - "organization": { - "reference": "Organization/" + organization_id, - "display": organization_name, - } - } - resource.update(org) - return resource - - -def update_list(resource, inventory_id, supply_date): - with open("json_payloads/inventory_location_list_payload.json") as json_file: - payload_string = json_file.read() - - payload_string = (payload_string.replace("$supply_date", supply_date) - .replace("$inventory_id", inventory_id)) - json_payload = json.loads(payload_string) - - try: - entries = resource["entry"] - if inventory_id not in str(entries): - entry = json_payload["entry"][0] - entries.append(entry) - - except KeyError: - entry = {"entry": json_payload["entry"]} - resource.update(entry) - return resource - - -def create_new_practitioner_role( - new_id, practitioner_name, practitioner_id, organization_name, organization_id -): - with open("json_payloads/practitioner_organization_payload.json") as json_file: - payload_string = json_file.read() - - payload_string = ( - payload_string.replace("$id", new_id) - .replace("$practitioner_id", practitioner_id) - .replace("$practitioner_name", practitioner_name) - .replace("$organization_id", organization_id) - .replace("$organization_name", organization_name) - ) - resource = json.loads(payload_string) - return resource - - -def create_new_list(new_id, location_id, inventory_id, title, supply_date): - with open("json_payloads/inventory_location_list_payload.json") as json_file: - payload_string = json_file.read() - - payload_string = ( - payload_string.replace("$id", new_id) - .replace("$title", title) - .replace("$location_id", location_id) - .replace("$supply_date", supply_date) - .replace("$inventory_id", inventory_id) - ) - resource = json.loads(payload_string) - return resource - - -def check_resource(subject, entries, resource_type, url_filter): - if subject not in entries.keys(): - base_url = get_base_url() - check_url = ( - base_url + "/" + resource_type + "/_search?_count=1&" + url_filter + subject - ) - response = handle_request("GET", "", check_url) - json_response = json.loads(response[0]) - - entries[subject] = json_response - - return entries - - -def build_assign_payload(rows, resource_type, url_filter): - bundle = {"resourceType": "Bundle", "type": "transaction", "entry": []} - - subject_id = item_id = organization_name = practitioner_name = inventory_name = ( - supply_date - ) = resource_id = version = "" - entries = {} - resource = {} - results = {} - - for row in rows: - if resource_type == "List": - # inventory_name, inventory_id, supply_date, location_id - inventory_name, item_id, supply_date, subject_id = row - if resource_type == "PractitionerRole": - # practitioner_name, practitioner_id, organization_name, organization_id - practitioner_name, subject_id, organization_name, item_id = row - - get_content = check_resource(subject_id, entries, resource_type, url_filter) - json_response = get_content[subject_id] - - if json_response["total"] == 1: - logging.info("Updating existing resource") - resource = json_response["entry"][0]["resource"] - - if resource_type == "PractitionerRole": - resource = update_practitioner_role( - resource, item_id, organization_name - ) - if resource_type == "List": - resource = update_list(resource, item_id, supply_date) - - if "meta" in resource: - version = resource["meta"]["versionId"] - resource_id = resource["id"] - del resource["meta"] - - elif json_response["total"] == 0: - logging.info("Creating a new resource") - resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, subject_id + item_id)) - - if resource_type == "PractitionerRole": - resource = create_new_practitioner_role( - resource_id, - practitioner_name, - subject_id, - organization_name, - item_id, - ) - if resource_type == "List": - resource = create_new_list( - resource_id, subject_id, item_id, inventory_name, supply_date - ) - version = "1" - - try: - resource["entry"] = ( - entries[subject_id]["resource"]["resource"]["entry"] - + resource["entry"] - ) - except KeyError: - logging.debug("No existing entries") - - else: - raise ValueError("The number of references should only be 0 or 1") - - payload = { - "request": { - "method": "PUT", - "url": resource_type + "/" + resource_id, - "ifMatch": version, - }, - "resource": resource, - } - entries[subject_id]["resource"] = payload - results[subject_id] = payload - - final_entries = [] - for entry in results: - final_entries.append(results[entry]) - - bundle["entry"] = final_entries - return json.dumps(bundle, indent=4) - - -def get_org_name(key, resource_list): - for x in resource_list: - if x[1] == key: - org_name = x[0] - - return org_name - - -def build_org_affiliation(resources, resource_list): - fp = """{"resourceType": "Bundle","type": "transaction","entry": [ """ - - with open("json_payloads/organization_affiliation_payload.json") as json_file: - payload_string = json_file.read() - - with click.progressbar( - resources, label="Progress::Build payload " - ) as build_progress: - for key in build_progress: - rp = "" - unique_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, key)) - org_name = get_org_name(key, resource_list) - - rp = ( - payload_string.replace("$unique_uuid", unique_uuid) - .replace("$identifier_uuid", unique_uuid) - .replace("$version", "1") - .replace("$orgID", key) - .replace("$orgName", org_name) - ) - - locations = [] - for x in resources[key]: - y = {} - z = x.split(":") - y["reference"] = "Location/" + str(z[0]) - y["display"] = str(z[1]) - locations.append(y) - - obj = json.loads(rp) - obj["resource"]["location"] = locations - rp = json.dumps(obj) - - fp = fp + rp + "," - - fp = fp[:-1] + " ] } " - return fp - - -# This function is used to Capitalize the 'resource_type' -# and remove the 's' at the end, a version suitable with the API -def get_valid_resource_type(resource_type): - logging.debug("Modify the string resource_type") - modified_resource_type = resource_type[0].upper() + resource_type[1:-1] - return modified_resource_type - - -# This function gets the current resource version from the API -def get_resource(resource_id, resource_type): - if resource_type != "Group": - resource_type = get_valid_resource_type(resource_type) - resource_url = "/".join([fhir_base_url, resource_type, resource_id]) - response = handle_request("GET", "", resource_url) - return json.loads(response[0])["meta"]["versionId"] if response[1] == 200 else "0" - - -def check_for_nulls(resource: list) -> list: - for index, value in enumerate(resource): - if len(value.strip()) < 1: - resource[index] = None - else: - resource[index] = value.strip() - return resource - - -# This function builds a json payload -# which is posted to the api to create resources -def build_payload(resource_type, resources, resource_payload_file): - logging.info("Building request payload") - initial_string = """{"resourceType": "Bundle","type": "transaction","entry": [ """ - final_string = " " - with open(resource_payload_file) as json_file: - payload_string = json_file.read() - - with click.progressbar( - resources, label="Progress::Building payload " - ) as build_payload_progress: - for resource in build_payload_progress: - logging.info("\t") - - resource = check_for_nulls(resource) - - try: - name, status, method, id, *_ = resource - except ValueError: - name = resource[0] - status = "" if len(resource) == 1 else resource[1] - method = "create" - id = str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) - - if method == "create": - version = "1" - if id: - unique_uuid = identifier_uuid = id - else: - unique_uuid = identifier_uuid = str( - uuid.uuid5(uuid.NAMESPACE_DNS, name) - ) - - if method == "update": - if id: - version = get_resource(id, resource_type) - - if version != "0": - unique_uuid = identifier_uuid = id - else: - logging.info("Failed to get resource!") - raise ValueError("Trying to update a Non-existent resource") - else: - logging.info("The id is required!") - raise ValueError("The id is required to update a resource") - - # ps = payload_string - ps = ( - payload_string.replace("$name", name) - .replace("$unique_uuid", unique_uuid) - .replace("$identifier_uuid", identifier_uuid) - .replace("$version", version) - ) - - try: - ps = ps.replace("$status", status) - except IndexError: - ps = ps.replace("$status", "active") - - if resource_type == "organizations": - ps = organization_extras(resource, ps) - elif resource_type == "locations": - ps = location_extras(resource, ps) - elif resource_type == "careTeams": - ps = care_team_extras(resource, ps, "orgs & users") - elif resource_type == "Group": - if "inventory" in resource_payload_file: - group_type = "inventory" - elif "product" in resource_payload_file: - group_type = "product" - else: - logging.error("Undefined group type") - ps = group_extras(resource, ps, group_type) - - final_string = final_string + ps + "," - - final_string = json.dumps(json.loads(initial_string + final_string[:-1] + " ] } ")) - return final_string - - -def confirm_keycloak_user(user): - # Confirm that the keycloak user details are as expected - user_username = str(user[2]).strip() - user_email = str(user[3]).strip() - keycloak_url = get_keycloak_url() - response = handle_request( - "GET", "", api_service.auth_options.keycloak_realm_uri + "/users?exact=true&username=" + user_username - ) - logging.debug(response) - json_response = json.loads(response[0]) - - try: - # TODO - apparently not all user uploads will have an email - print("============>", json_response) - response_email = json_response[0].get("email", "") - except IndexError: - response_email = "" - - try: - response_username = json_response[0]["username"] - except IndexError: - logging.error("Skipping user: " + str(user)) - logging.error("Username not found!") - return 0 - - if response_username != user_username: - logging.error("Skipping user: " + str(user)) - logging.error("Username does not match") - return 0 - - if len(response_email) > 0 and response_email != user_email: - logging.error("Email does not match for user: " + str(user)) - - keycloak_id = json_response[0]["id"] - logging.info("User confirmed with id: " + keycloak_id) - return keycloak_id - - -def confirm_practitioner(user, user_id): - practitioner_uuid = str(user[4]).strip() - base_url = get_base_url() - if not practitioner_uuid: - # If practitioner uuid not provided in csv, check if any practitioners exist linked to the keycloak user_id - r = handle_request("GET", "", base_url + "/Practitioner?identifier=" + user_id) - json_r = json.loads(r[0]) - counter = json_r["total"] - if counter > 0: - logging.info( - str(counter) + " Practitioner(s) exist, linked to the provided user" - ) - return True - else: - return False - - r = handle_request("GET", "", base_url + "/Practitioner/" + practitioner_uuid) - - if r[1] == 404: - logging.info("Practitioner does not exist, proceed to creation") - return False - else: - try: - json_r = json.loads(r[0]) - identifiers = json_r["identifier"] - keycloak_id = 0 - for id in identifiers: - if id["use"] == "secondary": - keycloak_id = id["value"] - - if str(keycloak_id) == user_id: - logging.info( - "The Keycloak user and Practitioner are linked as expected" - ) - return True - else: - logging.error( - "The Keycloak user and Practitioner are not linked as expected" - ) - return True - - except Exception as err: - logging.error("Error occurred trying to find Practitioner: " + str(err)) - return True - - -def create_roles(role_list, roles_max): - for role in role_list: - current_role = str(role[0]) - logging.debug("The current role is: " + current_role) - - # check if role already exists - role_response = handle_request( - "GET", "", keycloak_url + "/roles/" + current_role - ) - logging.debug(role_response) - if current_role in role_response[0]: - logging.error("A role already exists with the name " + current_role) - else: - role_payload = '{"name": "' + current_role + '"}' - create_role = handle_request( - "POST", role_payload, keycloak_url + "/roles" - ) - if create_role.status_code == 201: - logging.info("Successfully created role: " + current_role) - - try: - # check if role has composite roles - if role[1]: - logging.debug("Role has composite roles") - # get roled id - full_role = handle_request( - "GET", "", keycloak_url + "/roles/" + current_role - ) - json_resp = json.loads(full_role[0]) - role_id = json_resp["id"] - logging.debug("roleId: " + str(role_id)) - - # get all available roles - available_roles = handle_request( - "GET", - "", - keycloak_url - + "/admin-ui-available-roles/roles/" - + role_id - + "?first=0&max=" - + str(roles_max) - + "&search=", - ) - json_roles = json.loads(available_roles[0]) - logging.debug("json_roles: " + str(json_roles)) - - rolesMap = {} - - for jrole in json_roles: - # remove client and clientId, then rename role to name - # to build correct payload - del jrole["client"] - del jrole["clientId"] - jrole["name"] = jrole["role"] - del jrole["role"] - rolesMap[str(jrole["name"])] = jrole - - associated_roles = str(role[2]) - logging.debug("Associated roles: " + associated_roles) - associated_role_array = associated_roles.split("|") - arr = [] - for arole in associated_role_array: - if arole in rolesMap.keys(): - arr.append(rolesMap[arole]) - else: - logging.error("Role " + arole + "does not exist") - - payload_arr = json.dumps(arr) - handle_request( - "POST", - payload_arr, - keycloak_url + "/roles-by-id/" + role_id + "/composites", - ) - - except IndexError: - pass - - -def get_group_id(group): - # check if group exists - all_groups = handle_request("GET", "", keycloak_url + "/groups") - json_groups = json.loads(all_groups[0]) - group_obj = {} - - for agroup in json_groups: - group_obj[agroup["name"]] = agroup - - if group in group_obj.keys(): - gid = str(group_obj[group]["id"]) - logging.info("Group already exists with id : " + gid) - return gid - - else: - logging.info("Group does not exists, lets create it") - # create the group - create_group_payload = '{"name":"' + group + '"}' - handle_request("POST", create_group_payload, keycloak_url + "/groups") - return get_group_id(group) - - -def assign_group_roles(role_list, group, roles_max): - group_id = get_group_id(group) - logging.debug("The groupID is: " + group_id) - - # get available roles - available_roles_for_group = handle_request( - "GET", - "", - keycloak_url - + "/groups/" - + group_id - + "/role-mappings/realm/available?first=0&max=" - + str(roles_max), - ) - json_roles = json.loads(available_roles_for_group[0]) - role_obj = {} - - for j in json_roles: - role_obj[j["name"]] = j - - assign_payload = [] - for r in role_list: - if r[0] in role_obj.keys(): - assign_payload.append(role_obj[r[0]]) - - json_assign_payload = json.dumps(assign_payload) - handle_request( - "POST", - json_assign_payload, - keycloak_url + "/groups/" + group_id + "/role-mappings/realm", - ) - - -def delete_resource(resource_type, resource_id, cascade): - if cascade: - cascade = "?_cascade=delete" - else: - cascade = "" - - resource_url = "/".join( - [fhir_base_url, resource_type, resource_id + cascade] - ) - r = handle_request("DELETE", "", resource_url) - logging.info(r.text) - - -def clean_duplicates(users, cascade_delete): - for user in users: - # get keycloak user uuid - username = str(user[2].strip()) - user_details = handle_request( - "GET", "", keycloak_url + "/users?exact=true&username=" + username - ) - obj = json.loads(user_details[0]) - keycloak_uuid = obj[0]["id"] - - # get Practitioner(s) - r = handle_request( - "GET", - "", - fhir_base_url + "/Practitioner?identifier=" + keycloak_uuid, - ) - practitioner_details = json.loads(r[0]) - count = practitioner_details["total"] - - try: - practitioner_uuid_provided = str(user[4].strip()) - except IndexError: - practitioner_uuid_provided = None - - if practitioner_uuid_provided: - if count == 1: - practitioner_uuid_returned = practitioner_details["entry"][0][ - "resource" - ]["id"] - # confirm the uuid matches the one provided in csv - if practitioner_uuid_returned == practitioner_uuid_provided: - logging.info("User " + username + " ok!") - else: - logging.error( - "User " - + username - + "has 1 Practitioner but it does not match the provided uuid" - ) - elif count > 1: - for x in practitioner_details["entry"]: - p_uuid = x["resource"]["id"] - if practitioner_uuid_provided == p_uuid: - # This is the correct resource, so skip it - continue - else: - logging.info( - "Deleting practitioner resource with uuid: " + str(p_uuid) - ) - delete_resource("Practitioner", p_uuid, cascade_delete) - else: - # count is less than 1 - logging.info("No Practitioners found") - - -# Create a csv file and initialize the CSV writer -def write_csv(data, resource_type, fieldnames): - logging.info("Writing to csv file") - path = "csv/exports" - if not os.path.exists(path): - os.makedirs(path) - - current_time = datetime.now().strftime("%Y-%m-%d-%H-%M") - csv_file = f"{path}/{current_time}-export_{resource_type}.csv" - with open(csv_file, "w", newline="") as file: - csv_writer = csv.writer(file) - csv_writer.writerow(fieldnames) - with click.progressbar( - data, label="Progress:: Writing csv" - ) as write_csv_progress: - for row in write_csv_progress: - csv_writer.writerow(row) - return csv_file - - -def get_base_url(): - return api_service.fhir_base_uri - - -# This function exports resources from the API to a csv file -def export_resources_to_csv(resource_type, parameter, value, limit): - base_url = get_base_url() - resource_url = "/".join([str(base_url), resource_type]) - if len(parameter) > 0: - resource_url = ( - resource_url + "?" + parameter + "=" + value + "&_count=" + str(limit) - ) - response = handle_request("GET", "", resource_url) - if response[1] == 200: - resources = json.loads(response[0]) - data = [] - try: - if resources["entry"]: - if resource_type == "Location": - elements = [ - "name", - "status", - "method", - "id", - "identifier", - "parentName", - "parentID", - "type", - "typeCode", - "physicalType", - "physicalTypeCode", - ] - elif resource_type == "Organization": - elements = ["name", "active", "method", "id", "identifier"] - elif resource_type == "CareTeam": - elements = [ - "name", - "status", - "method", - "id", - "identifier", - "organizations", - "participants", - ] - else: - elements = [] - with click.progressbar( - resources["entry"], label="Progress:: Extracting resource" - ) as extract_resources_progress: - for x in extract_resources_progress: - rl = [] - orgs_list = [] - participants_list = [] - for element in elements: - try: - if element == "method": - value = "update" - elif element == "active": - value = x["resource"]["active"] - elif element == "identifier": - value = x["resource"]["identifier"][0]["value"] - elif element == "organizations": - organizations = x["resource"][ - "managingOrganization" - ] - for index, value in enumerate(organizations): - reference = x["resource"][ - "managingOrganization" - ][index]["reference"] - new_reference = reference.split("/", 1)[1] - display = x["resource"]["managingOrganization"][ - index - ]["display"] - organization = ":".join( - [new_reference, display] - ) - orgs_list.append(organization) - string = "|".join(map(str, orgs_list)) - value = string - elif element == "participants": - participants = x["resource"]["participant"] - for index, value in enumerate(participants): - reference = x["resource"]["participant"][index][ - "member" - ]["reference"] - new_reference = reference.split("/", 1)[1] - display = x["resource"]["participant"][index][ - "member" - ]["display"] - participant = ":".join([new_reference, display]) - participants_list.append(participant) - string = "|".join(map(str, participants_list)) - value = string - elif element == "parentName": - value = x["resource"]["partOf"]["display"] - elif element == "parentID": - reference = x["resource"]["partOf"]["reference"] - value = reference.split("/", 1)[1] - elif element == "type": - value = x["resource"]["type"][0]["coding"][0][ - "display" - ] - elif element == "typeCode": - value = x["resource"]["type"][0]["coding"][0][ - "code" - ] - elif element == "physicalType": - value = x["resource"]["physicalType"]["coding"][0][ - "display" - ] - elif element == "physicalTypeCode": - value = x["resource"]["physicalType"]["coding"][0][ - "code" - ] - else: - value = x["resource"][element] - except KeyError: - value = "" - rl.append(value) - data.append(rl) - write_csv(data, resource_type, elements) - logging.info("Successfully written to csv") - else: - logging.info("No entry found") - except KeyError: - logging.info("No Resources Found") - else: - logging.error( - f"Failed to retrieve resource. Status code: {response[1]} response: {response[0]}" - ) - - -def encode_image(image_file): - with open(image_file, "rb") as image: - image_b64_data = base64.b64encode(image.read()) - return image_b64_data - - -# This function takes in the source url of an image, downloads it, encodes it, -# and saves it as a Binary resource. It returns the id of the Binary resource if -# successful and 0 if failed -def save_image(image_source_url): - - try: - headers = {"Authorization": "Bearer " + product_access_token} - except AttributeError: - headers = {} - - data = requests.get(url=image_source_url, headers=headers) - if not os.path.exists("images"): - os.makedirs("images") - - if data.status_code == 200: - with open("images/image_file", "wb") as image_file: - image_file.write(data.content) - - # get file type - mime = magic.Magic(mime=True) - file_type = mime.from_file("images/image_file") - - encoded_image = encode_image("images/image_file") - resource_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, image_source_url)) - payload = { - "resourceType": "Bundle", - "type": "transaction", - "entry": [ - { - "request": { - "method": "PUT", - "url": "Binary/" + resource_id, - "ifMatch": "1", - }, - "resource": { - "resourceType": "Binary", - "id": resource_id, - "contentType": file_type, - "data": str(encoded_image), - }, - } - ], - } - payload_string = json.dumps(payload, indent=4) - response = handle_request("POST", payload_string, get_base_url()) - if response.status_code == 200: - logging.info("Binary resource created successfully") - logging.info(response.text) - return resource_id - else: - logging.error("Error while creating Binary resource") - logging.error(response.text) - return 0 - else: - logging.error("Error while attempting to retrieve image") - logging.error(data) - return 0 - - -def process_chunk(resources_array: list, resource_type: str): - new_arr = [] - with click.progressbar( - resources_array, label="Progress::Processing chunks ... " - ) as resources_array_progress: - for resource in resources_array_progress: - if not resource_type: - resource_type = resource["resourceType"] - try: - resource_id = resource["id"] - except KeyError: - if "identifier" in resource: - resource_identifier = resource["identifier"][0]["value"] - resource_id = str( - uuid.uuid5(uuid.NAMESPACE_DNS, resource_identifier) - ) - else: - resource_id = str(uuid.uuid4()) - - item = {"resource": resource, "request": {}} - item["request"]["method"] = "PUT" - item["request"]["url"] = "/".join([resource_type, resource_id]) - new_arr.append(item) - - json_payload = {"resourceType": "Bundle", "type": "transaction", "entry": new_arr} - - r = handle_request("POST", "", fhir_base_url, json_payload) - logging.info(r.text) - # TODO handle failures - - -def set_resource_list( - objs: str = None, - json_list: list = None, - resource_type: str = None, - number_of_resources: int = 100, -): - if objs: - resources_array = json.loads(objs) - process_chunk(resources_array, resource_type) - if json_list: - if len(json_list) > number_of_resources: - for i in range(0, len(json_list), number_of_resources): - sub_list = json_list[i : i + number_of_resources] - process_chunk(sub_list, resource_type) - else: - process_chunk(json_list, resource_type) - - -def build_mapped_payloads(resource_mapping, json_file, resources_count): - with open(json_file, "r") as file: - data_dict = json.load(file) - with click.progressbar( - resource_mapping, label="Progress::Setting up ... " - ) as resource_mapping_progress: - for resource_type in resource_mapping_progress: - index_positions = resource_mapping[resource_type] - resource_list = [data_dict[i] for i in index_positions] - set_resource_list(None, resource_list, resource_type, resources_count) - - -def build_resource_type_map(resources: str, mapping: dict, index_tracker: int = 0): - resource_list = json.loads(resources) - for index, resource in enumerate(resource_list): - resource_type = resource["resourceType"] - if resource_type in mapping.keys(): - mapping[resource_type].append(index + index_tracker) - else: - mapping[resource_type] = [index + index_tracker] - - global import_counter - import_counter = len(resource_list) + import_counter - - -def split_chunk( - chunk: str, - left_over_chunk: str, - size: int, - mapping: dict = None, - sync: str = None, - import_counter: int = 0, -): - if len(chunk) + len(left_over_chunk) < int(size): - # load can fit in one chunk, so remove closing bracket - last_bracket = chunk.rfind("}") - current_chunk = chunk[: int(last_bracket)] - next_left_over_chunk = "-" - if len(chunk.strip()) == 0: - last_bracket = left_over_chunk.rfind("}") - left_over_chunk = left_over_chunk[: int(last_bracket)] - else: - # load can't fit, so split on last full resource - split_index = chunk.rfind( - '},{"id"' - ) # Assumption that this string will find the last full resource - current_chunk = chunk[:split_index] - next_left_over_chunk = chunk[int(split_index) + 2 :] - if len(chunk.strip()) == 0: - last_bracket = left_over_chunk.rfind("}") - left_over_chunk = left_over_chunk[: int(last_bracket)] - - if len(left_over_chunk.strip()) == 0: - current_chunk = current_chunk[1:] - - chunk_list = "[" + left_over_chunk + current_chunk + "}]" - - if sync.lower() == "direct": - set_resource_list(chunk_list) - if sync.lower() == "sort": - build_resource_type_map(chunk_list, mapping, import_counter) - return next_left_over_chunk +import click +from importer.builder import (build_assign_payload, build_group_list_resource, + build_org_affiliation, build_payload, + extract_matches, extract_resources, link_to_location) +from importer.config.settings import fhir_base_url +from importer.request import handle_request +from importer.users import (assign_default_groups_roles, assign_group_roles, + confirm_keycloak_user, confirm_practitioner, + create_roles, create_user, create_user_resources) +from importer.utils import (build_mapped_payloads, clean_duplicates, + export_resources_to_csv, read_csv, + read_file_in_chunks) -def read_file_in_chunks(json_file: str, chunk_size: int, sync: str): - logging.info("Reading file in chunks ...") - incomplete_load = "" - mapping = {} - global import_counter - import_counter = 0 - with open(json_file, "r") as file: - while True: - chunk = file.read(chunk_size) - if not chunk: - break - incomplete_load = split_chunk( - chunk, incomplete_load, chunk_size, mapping, sync, import_counter - ) - return mapping +dir_path = str(pathlib.Path(__file__).parent.resolve()) class ResponseFilter(logging.Filter): @@ -1725,17 +50,14 @@ def filter(self, record): @click.command() @click.option("--csv_file", required=False) @click.option("--json_file", required=False) -@click.option("--access_token", required=False) @click.option("--resource_type", required=False) @click.option("--assign", required=False) @click.option("--setup", required=False) @click.option("--group", required=False) @click.option("--roles_max", required=False, default=500) +@click.option("--default_groups", required=False, default=True) @click.option("--cascade_delete", required=False, default=False) @click.option("--only_response", required=False) -@click.option( - "--log_level", type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False) -) @click.option("--export_resources", required=False) @click.option("--parameter", required=False, default="_lastUpdated") @click.option("--value", required=False, default="gt2023-01-01") @@ -1743,21 +65,30 @@ def filter(self, record): @click.option("--bulk_import", required=False, default=False) @click.option("--chunk_size", required=False, default=1000000) @click.option("--resources_count", required=False, default=100) +@click.option("--list_resource_id", required=False) +@click.option( + "--log_level", type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False) +) @click.option( "--sync", type=click.Choice(["DIRECT", "SORT"], case_sensitive=False), required=False, default="DIRECT", ) +@click.option( + "--location_type_coding_system", + required=False, + default="http://terminology.hl7.org/CodeSystem/location-type", +) def main( csv_file, json_file, - access_token, resource_type, assign, setup, group, roles_max, + default_groups, cascade_delete, only_response, log_level, @@ -1768,7 +99,9 @@ def main( bulk_import, chunk_size, resources_count, + list_resource_id, sync, + location_type_coding_system, ): if log_level == "DEBUG": logging.basicConfig( @@ -1784,7 +117,6 @@ def main( ) logging.getLogger().addHandler(logging.StreamHandler()) - # TODO - should be an empty flag that does not need a value. if only_response: logging.config.dictConfig(LOGGING) @@ -1808,15 +140,12 @@ def main( logging.info("Total time: " + str(total_time.total_seconds()) + " seconds") exit() - # set access token - if access_token: - global global_access_token - global_access_token = access_token - final_response = "" logging.info("Starting csv import...") + json_path = "/".join([dir_path, "json_payloads/"]) resource_list = read_csv(csv_file) + if resource_list: if resource_type == "users": logging.info("Processing users") @@ -1840,29 +169,28 @@ def main( logging.info("Processing complete!") elif resource_type == "locations": logging.info("Processing locations") - batch_generator = process_locations(resource_list) - final_response = [] - for batch in batch_generator: - json_payload = build_payload( - "locations", batch, "json_payloads/locations_payload.json" - ) - response = handle_request("POST", json_payload, fhir_base_url) - final_response.append(response.text) - final_response = ",\n".join(final_response) + json_payload = build_payload( + "locations", + resource_list, + "json_payloads/locations_payload.json", + None, + location_type_coding_system, + ) + final_response = handle_request("POST", json_payload, fhir_base_url) logging.info("Processing complete!") elif resource_type == "organizations": logging.info("Processing organizations") json_payload = build_payload( "organizations", resource_list, - "json_payloads/organizations_payload.json", + json_path + "organizations_payload.json", ) final_response = handle_request("POST", json_payload, fhir_base_url) logging.info("Processing complete!") elif resource_type == "careTeams": logging.info("Processing CareTeams") json_payload = build_payload( - "careTeams", resource_list, "json_payloads/careteams_payload.json" + "careTeams", resource_list, json_path + "careteams_payload.json" ) final_response = handle_request("POST", json_payload, fhir_base_url) logging.info("Processing complete!") @@ -1871,10 +199,13 @@ def main( matches = extract_matches(resource_list) json_payload = build_org_affiliation(matches, resource_list) final_response = handle_request("POST", json_payload, fhir_base_url) + logging.info(final_response) logging.info("Processing complete!") elif assign == "users-organizations": logging.info("Assigning practitioner to Organization") - json_payload = build_assign_payload(resource_list, "PractitionerRole") + json_payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) final_response = handle_request("POST", json_payload, fhir_base_url) logging.info("Processing complete!") elif setup == "roles": @@ -1883,6 +214,8 @@ def main( if group: assign_group_roles(resource_list, group, roles_max) logging.info("Processing complete") + if default_groups: + assign_default_groups_roles(roles_max) elif setup == "clean_duplicates": logging.info( "You are about to clean/delete Practitioner resources on the HAPI server" @@ -1892,28 +225,65 @@ def main( logging.info("Processing complete!") elif setup == "products": logging.info("Importing products as FHIR Group resources") - json_payload = build_payload( - "Group", resource_list, "json_payloads/product_group_payload.json" + json_payload, created_resources = build_payload( + "Group", resource_list, json_path + "product_group_payload.json", [] ) - final_response = handle_request("POST", json_payload, fhir_base_url) + product_creation_response = handle_request( + "POST", json_payload, fhir_base_url + ) + if product_creation_response.status_code == 200: + full_list_created_resources = extract_resources( + created_resources, product_creation_response.text + ) + list_payload = build_group_list_resource( + list_resource_id, + csv_file, + full_list_created_resources, + "Supply Inventory List", + ) + final_response = handle_request("POST", "", fhir_base_url, list_payload) + logging.info("Processing complete!") + else: + logging.error(product_creation_response.text) elif setup == "inventories": logging.info("Importing inventories as FHIR Group resources") json_payload = build_payload( - "Group", resource_list, "json_payloads/inventory_group_payload.json" + "Group", resource_list, json_path + "inventory_group_payload.json" ) - final_response = handle_request("POST", json_payload, fhir_base_url) + inventory_creation_response = handle_request( + "POST", json_payload, fhir_base_url + ) + groups_created = [] + if inventory_creation_response.status_code == 200: + groups_created = extract_resources( + groups_created, inventory_creation_response.text + ) + + lists_created = [] + link_payload = link_to_location(resource_list) + if len(link_payload) > 0: + link_response = handle_request("POST", link_payload, fhir_base_url) + if link_response.status_code == 200: + lists_created = extract_resources(lists_created, link_response.text) + logging.info(link_response.text) + + full_list_created_resources = groups_created + lists_created + if len(full_list_created_resources) > 0: + list_payload = build_group_list_resource( + list_resource_id, + csv_file, + full_list_created_resources, + "Supply Chain commodities", + ) + final_response = handle_request("POST", "", fhir_base_url, list_payload) + logging.info("Processing complete!") else: logging.error("Unsupported request!") else: logging.error("Empty csv file!") - - # TODO - final_response does not have text - trial uploading users that have already been uploaded - try: - final_response = final_response.text - except: - pass - logging.info('{ "final-response": ' + final_response + "}") + if final_response and final_response.text: + logging.info('{ "final-response": ' + final_response.text + "}") end_time = datetime.now() logging.info("End time: " + end_time.strftime("%H:%M:%S")) diff --git a/build/importer/pytest.ini b/build/importer/pytest.ini new file mode 100644 index 0000000..50e4045 --- /dev/null +++ b/build/importer/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +env = + client_id = 'example-client-id' + client_secret = 'example-client-secret' + fhir_base_url = 'https://example.smartregister.org/fhir' + keycloak_url = 'https://keycloak.smartregister.org/auth' + realm = 'realm' + username = 'example-username' + password = 'example-password' diff --git a/build/importer/requirements.txt b/build/importer/requirements.txt index a72ac03..7e68346 100644 --- a/build/importer/requirements.txt +++ b/build/importer/requirements.txt @@ -2,10 +2,12 @@ click==8.1.3 oauthlib==3.2.2 requests==2.31.0 requests-oauthlib==1.3.1 -urllib3==2.0.3 +urllib3==2.0.7 backoff==2.2.1 -pytest==7.4.2 +pytest>=7.4.2 jsonschema==4.21.1 mock==5.1.0 python-magic==0.4.27 jwt +python-dotenv==1.0.1 +pytest-env==1.1.3 diff --git a/build/importer/stub_data_gen/orgs-stup-gen.py b/build/importer/stub_data_gen/orgs-stup-gen.py deleted file mode 100644 index 92c02bc..0000000 --- a/build/importer/stub_data_gen/orgs-stup-gen.py +++ /dev/null @@ -1,35 +0,0 @@ -import csv -import uuid -from faker import Faker - -# Initialize Faker -fake = Faker() - -# Template data (header and sample row) -header = [ - "orgName", "orgActive", "method", "orgId", "identifier" -] - -# Function to generate random row data -def generate_random_row(): - org_name = fake.name() - active = fake.random_element(["true", "false", ""]) - method = "create" - id = fake.uuid4() - identifier = fake.uuid4() - - return [org_name, active, method, id, identifier] - -# Generate 100 rows of data -rows = [] -for _ in range(100): - rows.append(generate_random_row()) - -# Write to CSV file -filename = f"localCsvs/orgs.csv" -with open(filename, mode='w', newline='') as file: - writer = csv.writer(file) - writer.writerow(header) - writer.writerows(rows) - -print(f"CSV file '{filename}' with 100 rows has been created.") diff --git a/build/importer/stub_data_gen/users-stub-gen.py b/build/importer/stub_data_gen/users-stub-gen.py deleted file mode 100644 index 3dd1a32..0000000 --- a/build/importer/stub_data_gen/users-stub-gen.py +++ /dev/null @@ -1,42 +0,0 @@ -import csv -import uuid -from faker import Faker - -# Initialize Faker -fake = Faker() - -# Template data (header and sample row) -header = [ - "firstName", "lastName", "username", "email", "userId", - "userType", "enableUser", "keycloakGroupId", "keycloakGroupName", - "appId", "password" -] - -# Function to generate random row data -def generate_random_row(): - f_name = fake.first_name() - l_name = fake.last_name() - u_name = fake.user_name() - email = fake.email() - user_id = "" - user_type = fake.random_element(["Practitioner", "Supervisor", ""]) - enable_user = fake.random_element(["true", "false", ""]) - group_ids = "" - group_names = "" - app_id = "quest" - password = fake.password() - return [f_name, l_name, u_name, email, user_id, user_type, enable_user, group_ids, group_names, app_id, password] - -# Generate 100 rows of data -rows = [] -for _ in range(100): - rows.append(generate_random_row()) - -# Write to CSV file -filename = f"./users.csv" -with open(filename, mode='w', newline='') as file: - writer = csv.writer(file) - writer.writerow(header) - writer.writerows(rows) - -print(f"CSV file '{filename}' with 100 rows has been created.") diff --git a/build/importer/tests/__init__.py b/build/importer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/importer/tests/test_main.py b/build/importer/tests/test_builder.py similarity index 52% rename from build/importer/tests/test_main.py rename to build/importer/tests/test_builder.py index 9f7d857..2392868 100644 --- a/build/importer/tests/test_main.py +++ b/build/importer/tests/test_builder.py @@ -1,66 +1,30 @@ import json +import pathlib import unittest + from jsonschema import validate from mock import patch -from main import ( - read_csv, - write_csv, - build_payload, - build_org_affiliation, - extract_matches, - create_user_resources, - export_resources_to_csv, - build_assign_payload, - create_user, - confirm_keycloak_user, - confirm_practitioner, - check_parent_admin_level, - split_chunk, - read_file_in_chunks, -) - - -class TestMain(unittest.TestCase): - def test_read_csv(self): - csv_file = "../csv/users.csv" - records = read_csv(csv_file) - self.assertIsInstance(records, list) - self.assertEqual(len(records), 3) - - def test_write_csv(self): - self.test_data = [ - [ - "e2e-mom", - "True", - "update", - "caffe509-ae56-4d42-945e-7b4c161723d1", - "d93ae7c3-73c0-43d1-9046-425a3466ecec", - ], - [ - "e2e-skate", - "True", - "update", - "2d4feac9-9ab5-4585-9b33-e5abd14ceb0f", - "58605ed8-7217-4bf3-8122-229b6f47fa64", - ], - ] - self.test_resource_type = "test_organization" - self.test_fieldnames = ["name", "active", "method", "id", "identifier"] - csv_file = write_csv( - self.test_data, self.test_resource_type, self.test_fieldnames - ) - csv_content = read_csv(csv_file) - self.assertEqual(csv_content, self.test_data) - @patch("main.get_resource") +from importer.builder import (build_assign_payload, build_org_affiliation, + build_payload, check_parent_admin_level, + extract_matches, extract_resources, + process_resources_list) +from importer.utils import read_csv + +dir_path = str(pathlib.Path(__file__).parent.resolve()) +csv_path = dir_path + "/../csv/" +json_path = dir_path + "/../json_payloads/" + + +class TestBuilder(unittest.TestCase): + @patch("importer.builder.get_resource") def test_build_payload_organizations(self, mock_get_resource): mock_get_resource.return_value = "1" - csv_file = "../csv/organizations/organizations_full.csv" + csv_file = csv_path + "organizations/organizations_full.csv" resource_list = read_csv(csv_file) - payload = build_payload( - "organizations", resource_list, "../json_payloads/organizations_payload.json" - ) + json_file = json_path + "organizations_payload.json" + payload = build_payload("organizations", resource_list, json_file) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) self.assertEqual(payload_obj["resourceType"], "Bundle") @@ -90,11 +54,9 @@ def test_build_payload_organizations(self, mock_get_resource): validate(payload_obj["entry"][2]["request"], request_schema) # TestCase organizations_min.csv - csv_file = "../csv/organizations/organizations_min.csv" + csv_file = csv_path + "organizations/organizations_min.csv" resource_list = read_csv(csv_file) - payload = build_payload( - "organizations", resource_list, "../json_payloads/organizations_payload.json" - ) + payload = build_payload("organizations", resource_list, json_file) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) self.assertEqual(payload_obj["resourceType"], "Bundle") @@ -123,18 +85,23 @@ def test_build_payload_organizations(self, mock_get_resource): } validate(payload_obj["entry"][0]["resource"], request_schema) - @patch("main.check_parent_admin_level") - @patch("main.get_resource") + @patch("importer.builder.check_parent_admin_level") + @patch("importer.builder.get_resource") def test_build_payload_locations( self, mock_get_resource, mock_check_parent_admin_level ): mock_get_resource.return_value = "1" mock_check_parent_admin_level.return_value = "3" - csv_file = "../csv/locations/locations_full.csv" + csv_file = csv_path + "locations/locations_full.csv" resource_list = read_csv(csv_file) + json_file = json_path + "locations_payload.json" payload = build_payload( - "locations", resource_list, "../json_payloads/locations_payload.json" + "locations", + resource_list, + json_file, + None, + "http://terminology.hl7.org/CodeSystem/location-type", ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -220,10 +187,14 @@ def test_build_payload_locations( validate(payload_obj["entry"][0]["request"], request_schema) # TestCase locations_min.csv - csv_file = "../csv/locations/locations_min.csv" + csv_file = csv_path + "locations/locations_min.csv" resource_list = read_csv(csv_file) payload = build_payload( - "locations", resource_list, "../json_payloads/locations_payload.json" + "locations", + resource_list, + json_file, + None, + "http://terminology.hl7.org/CodeSystem/location-type", ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -253,8 +224,8 @@ def test_build_payload_locations( } validate(payload_obj["entry"][0]["resource"], request_schema) - @patch("main.handle_request") - @patch("main.get_base_url") + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") def test_check_parent_admin_level(self, mock_get_base_url, mock_handle_request): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" mocked_response_text = { @@ -279,18 +250,18 @@ def test_check_parent_admin_level(self, mock_get_base_url, mock_handle_request): } string_mocked_response_text = json.dumps(mocked_response_text) mock_handle_request.return_value = (string_mocked_response_text, 200) - locationParentId = "18fcbc2e-4240-4a84-a270-7a444523d7b6" - admin_level = check_parent_admin_level(locationParentId) + location_parent_id = "18fcbc2e-4240-4a84-a270-7a444523d7b6" + admin_level = check_parent_admin_level(location_parent_id) self.assertEqual(admin_level, "3") - @patch("main.get_resource") + @patch("importer.builder.get_resource") def test_build_payload_care_teams(self, mock_get_resource): mock_get_resource.return_value = "1" - csv_file = "../csv/careteams/careteam_full.csv" + csv_file = csv_path + "careteams/careteam_full.csv" resource_list = read_csv(csv_file) payload = build_payload( - "careTeams", resource_list, "../json_payloads/careteams_payload.json" + "careTeams", resource_list, json_path + "careteams_payload.json" ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -380,18 +351,19 @@ def test_build_payload_care_teams(self, mock_get_resource): } validate(payload_obj["entry"][0]["request"], request_schema) - @patch("main.save_image") - @patch("main.get_resource") + @patch("importer.builder.save_image") + @patch("importer.builder.get_resource") def test_build_payload_group(self, mock_get_resource, mock_save_image): mock_get_resource.return_value = "1" mock_save_image.return_value = "f374a23a-3c6a-4167-9970-b10c16a91bbd" - csv_file = "../csv/import/product.csv" + csv_file = csv_path + "import/product.csv" resource_list = read_csv(csv_file) - payload = build_payload( - "Group", resource_list, "../json_payloads/product_group_payload.json" + payload, list_resource = build_payload( + "Group", resource_list, json_path + "product_group_payload.json", [] ) payload_obj = json.loads(payload) + self.assertEqual(list_resource, ["Binary/f374a23a-3c6a-4167-9970-b10c16a91bbd"]) self.assertIsInstance(payload_obj, dict) self.assertEqual(payload_obj["resourceType"], "Bundle") @@ -435,8 +407,60 @@ def test_build_payload_group(self, mock_get_resource, mock_save_image): } validate(payload_obj["entry"][0]["request"], request_schema) + def test_build_payload_group_reference_list(self): + binary_resources = ["Binary/df620fe8-eeaa-47c6-809c-84252e22980a"] + response_string = ( + '{"entry": [{"response": {"location": ' + '"Group/ce64e19d-6d8a-4ef0-8fc6-1da83783aea8/_history/1"}}, {"response": ' + '{"location": "Group/aedd3c1a-5de8-45d5-8b35-5c288ccbb761/_history/1"}}]}' + ) + expected_resource_list = [ + "Binary/df620fe8-eeaa-47c6-809c-84252e22980a", + "Group/ce64e19d-6d8a-4ef0-8fc6-1da83783aea8", + "Group/aedd3c1a-5de8-45d5-8b35-5c288ccbb761", + ] + + created_resources = extract_resources(binary_resources, response_string) + self.assertEqual(created_resources, expected_resource_list) + + resource = [ + [ + "Supply Inventory List", + "current", + "create", + "77dae131-fd5d-4585-95db-2dd2b569d7a1", + ] + ] + result_payload = build_payload( + "List", resource, json_path + "group_list_payload.json" + ) + full_list_payload = process_resources_list(result_payload, created_resources) + + resource_schema = { + "type": "object", + "properties": { + "resourceType": {"const": "List"}, + "id": {"const": "77dae131-fd5d-4585-95db-2dd2b569d7a1"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "status": {"const": "current"}, + "mode": {"const": "working"}, + "title": {"const": "Supply Inventory List"}, + "entry": {"type": "array", "minItems": 3, "maxItems": 3}, + }, + "required": [ + "resourceType", + "id", + "identifier", + "status", + "mode", + "title", + "entry", + ], + } + validate(full_list_payload["entry"][0]["resource"], resource_schema) + def test_extract_matches(self): - csv_file = "../csv/organizations/organizations_locations.csv" + csv_file = csv_path + "organizations/organizations_locations.csv" resource_list = read_csv(csv_file) resources = extract_matches(resource_list) expected_matches = { @@ -450,7 +474,7 @@ def test_extract_matches(self): self.assertEqual(resources, expected_matches) def test_build_org_affiliation(self): - csv_file = "../csv/organizations/organizations_locations.csv" + csv_file = csv_path + "organizations/organizations_locations.csv" resource_list = read_csv(csv_file) resources = extract_matches(resource_list) payload = build_org_affiliation(resources, resource_list) @@ -459,74 +483,6 @@ def test_build_org_affiliation(self): self.assertEqual(payload_obj["resourceType"], "Bundle") self.assertEqual(len(payload_obj["entry"]), 2) - def test_uuid_generated_in_creating_user_resources_is_unique_and_repeatable(self): - users = [ - [ - "Jane", - "Doe", - "Janey", - "jdoe@example.com", - "", - "Practitioner", - "true", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ], - [ - "John", - "Doe", - "Janey", - "jodoe@example.com", - "", - "Practitioner", - "true", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ], - [ - "Janice", - "Doe", - "Jenn", - "jendoe@example.com", - "99d54e3c-c26f-4500-a7f9-3f4cb788673f", - "Supervisor", - "false", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ], - ] - - users_uuids = {} - for user_id, user in enumerate(users): - payload = create_user_resources(user[4], user) - payload_obj = json.loads(payload) - practitioner_uuid = payload_obj["entry"][0]["resource"]["id"] - group_uuid = payload_obj["entry"][1]["resource"]["id"] - practitioner_role_uuid = payload_obj["entry"][2]["resource"]["id"] - users_uuids[user_id] = [ - practitioner_uuid, - group_uuid, - practitioner_role_uuid, - ] - - # practitioner_uuid - self.assertEqual(users_uuids[0][0], users_uuids[1][0]) - self.assertNotEqual(users_uuids[1][0], users_uuids[2][0]) - - # group_uuid - self.assertEqual(users_uuids[0][1], users_uuids[1][1]) - self.assertNotEqual(users_uuids[1][1], users_uuids[2][1]) - - # practitioner_role_uuid - self.assertEqual(users_uuids[0][2], users_uuids[1][2]) - self.assertNotEqual(users_uuids[1][2], users_uuids[2][2]) - def test_uuid_generated_for_locations_is_unique_and_repeatable(self): resources = [ [ @@ -577,7 +533,11 @@ def test_uuid_generated_for_locations_is_unique_and_repeatable(self): ] payload = build_payload( - "locations", resources, "../json_payloads/locations_payload.json" + "locations", + resources, + json_path + "locations_payload.json", + None, + "http://terminology.hl7.org/CodeSystem/location-type", ) payload_obj = json.loads(payload) location1 = payload_obj["entry"][0]["resource"]["id"] @@ -627,14 +587,12 @@ def test_update_resource_with_no_id_fails(self): ] ] with self.assertRaises(ValueError) as raised_error: - build_payload( - "locations", resources, "../json_payloads/locations_payload.json" - ) + build_payload("locations", resources, json_path + "locations_payload.json") self.assertEqual( "The id is required to update a resource", str(raised_error.exception) ) - @patch("main.get_resource") + @patch("importer.builder.get_resource") def test_update_resource_with_non_existing_id_fails(self, mock_get_resource): mock_get_resource.return_value = "0" non_existing_id = "123" @@ -653,82 +611,13 @@ def test_update_resource_with_non_existing_id_fails(self, mock_get_resource): ] ] with self.assertRaises(ValueError) as raised_error: - build_payload( - "locations", resources, "../json_payloads/locations_payload.json" - ) + build_payload("locations", resources, json_path + "locations_payload.json") self.assertEqual( "Trying to update a Non-existent resource", str(raised_error.exception) ) - @patch("main.write_csv") - @patch("main.handle_request") - @patch("main.get_base_url") - def test_export_resource_to_csv( - self, mock_get_base_url, mock_handle_request, mock_write_csv - ): - mock_get_base_url.return_value = "https://example.smartregister.org/fhir" - mock_response_data = { - "entry": [ - { - "resource": { - "name": "City1", - "status": "active", - "id": "ba787982-b973-4bd5-854e-eacbe161e297", - "identifier": [ - {"value": "ba787 982-b973-4bd5-854e-eacbe161e297"} - ], - "partOf": { - "display": "test location-1", - "reference": "Location/18fcbc2e-4240-4a84-a270" - "-7a444523d7b6", - }, - "type": [ - {"coding": [{"display": "Jurisdiction", "code": "jdn"}]} - ], - "physicalType": { - "coding": [{"display": "Jurisdiction", "code": "jdn"}] - }, - } - } - ] - } - string_response = json.dumps(mock_response_data) - mock_response = (string_response, 200) - mock_handle_request.return_value = mock_response - test_data = [ - [ - "City1", - "active", - "update", - "ba787982-b973-4bd5-854e-eacbe161e297", - "ba787 982-b973-4bd5-854e-eacbe161e297", - "test location-1", - "18fcbc2e-4240-4a84-a270-7a444523d7b6", - "Jurisdiction", - "jdn", - "Jurisdiction", - "jdn", - ] - ] - test_elements = [ - "name", - "status", - "method", - "id", - "identifier", - "parentName", - "parentID", - "type", - "typeCode", - "physicalType", - "physicalTypeCode", - ] - resource_type = "Location" - export_resources_to_csv("Location", "_lastUpdated", "gt2023-08-01", 1) - mock_write_csv.assert_called_once_with(test_data, resource_type, test_elements) - - @patch("main.handle_request") - @patch("main.get_base_url") + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") def test_build_assign_payload_update_assigned_org( self, mock_get_base_url, mock_handle_request ): @@ -766,7 +655,9 @@ def test_build_assign_payload_update_assigned_org( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -789,8 +680,8 @@ def test_build_assign_payload_update_assigned_org( payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" ) - @patch("main.handle_request") - @patch("main.get_base_url") + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") def test_build_assign_payload_create_org_assignment( self, mock_get_base_url, mock_handle_request ): @@ -824,7 +715,9 @@ def test_build_assign_payload_create_org_assignment( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -843,8 +736,8 @@ def test_build_assign_payload_create_org_assignment( payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" ) - @patch("main.handle_request") - @patch("main.get_base_url") + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") def test_build_assign_payload_create_new_practitioner_role( self, mock_get_base_url, mock_handle_request ): @@ -862,7 +755,9 @@ def test_build_assign_payload_create_new_practitioner_role( "98199caa-4455-4b2f-a5cf-cb9c89b6bbdc", ] ] - payload = build_assign_payload(resource_list, "PractitionerRole") + payload = build_assign_payload( + resource_list, "PractitionerRole", "practitioner=Practitioner/" + ) payload_obj = json.loads(payload) self.assertIsInstance(payload_obj, dict) @@ -884,443 +779,185 @@ def test_build_assign_payload_create_new_practitioner_role( payload_obj["entry"][0]["resource"]["organization"]["display"], "New Org" ) - @patch("main.logging") - @patch("main.handle_request") - @patch("main.get_keycloak_url") - def test_create_user( - self, mock_get_keycloak_url, mock_handle_request, mock_logging + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") + def test_build_assign_payload_create_new_link_location_to_inventory_list( + self, mock_get_base_url, mock_handle_request ): - mock_get_keycloak_url.return_value = ( - "https://keycloak.smartregister.org/auth/admin/realms/example-realm" - ) - mock_handle_request.return_value.status_code = 201 - mock_handle_request.return_value.headers = { - "Location": "https://keycloak.smartregister.org/auth/admin/realms" - "/example-realm/users/6cd50351-3ddb-4296-b1db" - "-aac2273e35f3" - } - mocked_user_data = ( - "Jenn", - "Doe", - "Jenny", - "jeendoe@example.com", - "431cb523-253f-4c44-9ded-af42c55c0bbb", - "Supervisor", - "TRUE", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ) - user_id = create_user(mocked_user_data) + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mock_response_data = {"resourceType": "Bundle", "total": 0} + string_response = json.dumps(mock_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ] + ] + payload = build_assign_payload(resource_list, "List", "subject=List/") + payload_obj = json.loads(payload) - self.assertEqual(user_id, "6cd50351-3ddb-4296-b1db-aac2273e35f3") - mock_logging.info.assert_called_with("Setting user password") + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 1) - @patch("main.handle_request") - @patch("main.get_keycloak_url") - def test_create_user_already_exists( - self, mock_get_keycloak_url, mock_handle_request - ): - mock_get_keycloak_url.return_value = ( - "https://keycloak.smartregister.org/auth/admin/realms/example-realm" - ) - mock_handle_request.return_value.status_code = 409 - mocked_user_data = ( - "Jenn", - "Doe", - "Jenn", - "jendoe@example.com", - " 99d54e3c-c26f-4500-a7f9-3f4cb788673f", - "Supervisor", - "false", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", + self.assertEqual( + payload_obj["entry"][0]["resource"]["title"], "Nairobi Inventory Items" ) - user_id = create_user(mocked_user_data) - self.assertEqual(user_id, 0) - - # Test the confirm_keycloak function - @patch("main.logging") - @patch("main.handle_request") - @patch("main.get_keycloak_url") - def test_confirm_keycloak_user( - self, mock_get_keycloak_url, mock_handle_request, mock_logging - ): - mock_get_keycloak_url.return_value = ( - "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", ) - mocked_user_data = ( - "Jenn", - "Doe", - "Jenny", - "jeendoe@example.com", - "431cb523-253f-4c44-9ded-af42c55c0bbb", - "Supervisor", - "TRUE", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["date"], + "2024-06-01T10:40:10.111Z", ) - user_id = create_user(mocked_user_data) - self.assertEqual(user_id, 0) - - mock_response = ( - '[{"id":"6cd50351-3ddb-4296-b1db-aac2273e35f3","createdTimestamp":1710151827166,' - '"username":"Jenny","enabled":true,"totp":false,"emailVerified":false,"firstName":"Jenn",' - '"lastName":"Doe","email":"jeendoe@example.com","attributes":{"fhir_core_app_id":["demo"]},' - '"disableableCredentialTypes":[],"requiredActions":[],"notBefore":0,"access":{' - '"manageGroupMembership":true,"view":true,"mapRoles":true,"impersonate":true,' - '"manage":true}}]', - 200, + self.assertEqual( + payload_obj["entry"][0]["resource"]["subject"]["reference"], + "Location/3af23539-850a-44ed-8fb1-d4999e2145ff", ) - mock_handle_request.return_value = mock_response - mock_json_response = json.loads(mock_response[0]) - keycloak_id = confirm_keycloak_user(mocked_user_data) - self.assertEqual(mock_json_response[0]["username"], "Jenny") - self.assertEqual(mock_json_response[0]["email"], "jeendoe@example.com") - mock_logging.info.assert_called_with("User confirmed with id: " + keycloak_id) - - # Test confirm_practitioner function - @patch("main.handle_request") - @patch("main.get_base_url") - def test_confirm_practitioner_if_practitioner_uuid_not_provided( + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") + def test_build_assign_payload_update_location_with_new_inventory( self, mock_get_base_url, mock_handle_request ): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" - mocked_user = ( - "Jenn", - "Doe", - "Jenny", - "jeendoe@example.com", - "", - "Supervisor", - "TRUE", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ) - mocked_response_data = { + mock_response_data = { "resourceType": "Bundle", - "type": "searchset", "total": 1, + "entry": [ + { + "resource": { + "resourceType": "List", + "id": "6d7d2e70-1c90-11db-861d-0242ac120002", + "meta": {"versionId": "2"}, + "subject": { + "reference": "Location/46bb8a3f-cf50-4cc2-b421-fe4f77c3e75d" + }, + "entry": [ + { + "item": { + "reference": "Group/f2734756-a6bb-4e90-bbc6-1c34f51d3d5c" + } + } + ], + } + } + ], } - string_response = json.dumps(mocked_response_data) + string_response = json.dumps(mock_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response - practitioner_exists = confirm_practitioner( - mocked_user, "431cb523-253f-4c44-9ded-af42c55c0bbb" + + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ] + ] + + payload = build_assign_payload(resource_list, "List", "subject=List/") + payload_obj = json.loads(payload) + + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 1) + + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/f2734756-a6bb-4e90-bbc6-1c34f51d3d5c", ) - self.assertTrue( - practitioner_exists, "Practitioner exist, linked to the provided user" + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][1]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", ) - @patch("main.logging") - @patch("main.handle_request") - @patch("main.get_base_url") - def test_confirm_practitioner_linked_keycloak_user_and_practitioner( - self, mock_get_base_url, mock_handle_request, mock_logging + @patch("importer.builder.handle_request") + @patch("importer.builder.get_base_url") + def test_build_assign_payload_create_new_link_location_to_inventory_list_with_multiples( + self, mock_get_base_url, mock_handle_request ): mock_get_base_url.return_value = "https://example.smartregister.org/fhir" - mocked_user = ( - "Jenn", - "Doe", - "Jenny", - "jeendoe@example.com", - "6cd50351-3ddb-4296-b1db-aac2273e35f3", - "Supervisor", - "TRUE", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ) - mocked_response_data = { - "resourceType": "Practitioner", - "identifier": [ - {"use": "official", "value": "431cb523-253f-4c44-9ded-af42c55c0bbb"}, - {"use": "secondary", "value": "6cd50351-3ddb-4296-b1db-aac2273e35f3"}, - ], - } - string_response = json.dumps(mocked_response_data) + mock_response_data = {"resourceType": "Bundle", "total": 0} + string_response = json.dumps(mock_response_data) mock_response = (string_response, 200) mock_handle_request.return_value = mock_response - practitioner_exists = confirm_practitioner( - mocked_user, "6cd50351-3ddb-4296-b1db-aac2273e35f3" - ) - self.assertTrue(practitioner_exists) - self.assertEqual( - mocked_response_data["identifier"][1]["value"], - "6cd50351-3ddb-4296-b1db-aac2273e35f3", - ) - mock_logging.info.assert_called_with( - "The Keycloak user and Practitioner are linked as expected" - ) - # Test create_user_resources function - def test_create_user_resources(self): - user = ( - "Jenn", - "Doe", - "Jenn", - "jendoe@example.com", - "99d54e3c-c26f-4500-a7f9-3f4cb788673f", - "Supervisor", - "false", - "a715b562-27f2-432a-b1ba-e57db35e0f93", - "test", - "demo", - "pa$$word", - ) - user_id = "99d54e3c-c26f-4500-a7f9-3f4cb788673f" - payload = create_user_resources(user_id, user) + resource_list = [ + [ + "Nairobi Inventory Items", + "e62a049f-8d48-456c-a387-f52e72c39c74", + "2024-06-01T10:40:10.111Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ], + [ + "Nairobi Inventory Items", + "a36b595c-68a7-4244-91d5-c64be23b1ebd", + "2024-06-05T30:30:30.264Z", + "3af23539-850a-44ed-8fb1-d4999e2145ff", + ], + [ + "Mombasa Inventory Items", + "c0666a5a-00f6-488c-9001-8630560b5810", + "2024-06-06T55:23:19.492Z", + "3cd687a4-a169-45b3-a939-0418470c29db", + ], + ] + payload = build_assign_payload(resource_list, "List", "subject=List/") payload_obj = json.loads(payload) + self.assertIsInstance(payload_obj, dict) self.assertEqual(payload_obj["resourceType"], "Bundle") - self.assertEqual(len(payload_obj["entry"]), 3) - - resource_schema = { - "type": "object", - "properties": { - "resourceType": {"const": "Practitioner"}, - "id": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, - "identifier": { - "type": "array", - "items": { - "type": "object", - "properties": { - "use": { - "type": "string", - "enum": ["official", "secondary"], - }, - "type": { - "type": "object", - "properties": { - "coding": { - "type": "array", - "items": { - "type": "object", - "properties": { - "system": { - "const": "http://hl7.org/fhir/identifier-type" - }, - "code": {"const": "KUID"}, - "display": { - "const": "Keycloak user ID" - }, - }, - }, - }, - "text": {"const": "Keycloak user ID"}, - }, - }, - "value": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, - }, - }, - }, - "name": { - "type": "array", - "items": { - "type": "object", - "properties": { - "use": {"const": "official"}, - "family": {"const": "Doe"}, - "given": {"type": "array", "items": {"type": "string"}}, - }, - }, - }, - }, - "required": ["resourceType", "id", "identifier", "name"], - } - validate(payload_obj["entry"][0]["resource"], resource_schema) - - request_schema = { - "type": "object", - "properties": { - "method": {"const": "PUT"}, - "url": {"const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, - "ifMatch": {"const": "1"}, - }, - } - validate(payload_obj["entry"][0]["request"], request_schema) - - resource_schema = { - "type": "object", - "properties": { - "resourceType": {"const": "Group"}, - "id": {"const": "0de5f541-65ca-5504-ad6b-9b386e5f8810"}, - "identifier": {"type": "array", "items": {"type": "object"}}, - "name": {"const": "Jenn Doe"}, - "member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "entity": { - "type": "object", - "properties": { - "reference": { - "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" - } - }, - } - }, - }, - }, - }, - "required": ["resourceType", "id", "identifier", "name", "member"], - } - validate(payload_obj["entry"][1]["resource"], resource_schema) - - request_schema = { - "type": "object", - "properties": { - "method": {"const": "PUT"}, - "url": {"const": "Group/0de5f541-65ca-5504-ad6b-9b386e5f8810"}, - "ifMatch": {"const": "1"}, - }, - } - validate(payload_obj["entry"][1]["request"], request_schema) - - resource_schema = { - "type": "object", - "properties": { - "resourceType": {"const": "PractitionerRole"}, - "id": {"const": "f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3"}, - "identifier": {"type": "array", "items": {"type": "object"}}, - "practitioner": { - "type": "object", - "properties": { - "reference": { - "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" - }, - "display": {"const": "Jenn Doe"}, - }, - }, - "code": { - "type": "object", - "properties": { - "coding": { - "type": "array", - "items": { - "type": "object", - "properties": { - "system": {"const": "http://snomed.info/sct"}, - "code": {"const": "236321002"}, - "display": {"const": "Supervisor (occupation)"}, - }, - }, - } - }, - }, - }, - "required": ["resourceType", "id", "identifier", "practitioner", "code"], - } - validate(payload_obj["entry"][2]["resource"], resource_schema) + self.assertEqual(len(payload_obj["entry"]), 2) + self.assertEqual(len(payload_obj["entry"][0]["resource"]["entry"]), 2) + self.assertEqual(len(payload_obj["entry"][1]["resource"]["entry"]), 1) - request_schema = { - "type": "object", - "properties": { - "method": {"const": "PUT"}, - "url": { - "const": "PractitionerRole/f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3" - }, - "ifMatch": {"const": "1"}, - }, - } - validate(payload_obj["entry"][2]["request"], request_schema) + self.assertEqual( + payload_obj["entry"][0]["resource"]["title"], "Nairobi Inventory Items" + ) + self.assertEqual( + payload_obj["entry"][1]["resource"]["title"], "Mombasa Inventory Items" + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][0]["item"]["reference"], + "Group/e62a049f-8d48-456c-a387-f52e72c39c74", + ) + self.assertEqual( + payload_obj["entry"][0]["resource"]["entry"][1]["item"]["reference"], + "Group/a36b595c-68a7-4244-91d5-c64be23b1ebd", + ) + self.assertEqual( + payload_obj["entry"][1]["resource"]["entry"][0]["item"]["reference"], + "Group/c0666a5a-00f6-488c-9001-8630560b5810", + ) - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_first_chunk_less_than_size( - self, mock_set_resource_list - ): - chunk = '[{"id": "10", "resourceType": "Patient"}' - next_left_over = split_chunk(chunk, "", 50, {}, "direct") - chunk_list = '[{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, "-") - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_middle_chunk_less_than_size( - self, mock_set_resource_list - ): - chunk = ' "resourceType": "Patient"}' - left_over_chunk = '{"id": "10",' - next_left_over = split_chunk(chunk, left_over_chunk, 50, {}, "direct") - chunk_list = '[{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, "-") - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_last_chunk_less_than_size( - self, mock_set_resource_list - ): - left_over_chunk = '{"id": "10", "resourceType": "Patient"}]' - next_left_over = split_chunk("", left_over_chunk, 50, {}, "direct") - chunk_list = '[{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, "-") - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_first_chunk_greater_than_size( - self, mock_set_resource_list - ): - chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' - next_left_over = split_chunk(chunk, "", 40, {}, "direct") - chunk_list = '[{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, '{"id": "11", "resourceType":') - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_middle_chunk_greater_than_size( - self, mock_set_resource_list - ): - chunk = ': "Task"},{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' - left_over_chunk = '{"id": "09", "resourceType"' - next_left_over = split_chunk(chunk, left_over_chunk, 80, {}, "direct") - chunk_list = '[{"id": "09", "resourceType": "Task"},{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, '{"id": "11", "resourceType":') - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - def test_split_chunk_direct_sync_last_chunk_greater_than_size( - self, mock_set_resource_list - ): - left_over_chunk = '{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' - next_left_over = split_chunk("", left_over_chunk, 43, {}, "direct") - chunk_list = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' - self.assertEqual(next_left_over, "") - mock_set_resource_list.assert_called_once_with(chunk_list) - - @patch("main.set_resource_list") - @patch("main.build_resource_type_map") - def test_split_chunk_sort_sync_first_chunk_less_than_size( - self, mock_build_resource_type_map, mock_set_resource_list + @patch("importer.builder.check_parent_admin_level") + @patch("importer.builder.get_resource") + def test_define_own_location_type_coding_system_url( + self, mock_get_resource, mock_check_parent_admin_level ): - chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11"' - next_left_over = split_chunk(chunk, "", 50, {}, "sort") - chunk_list = '[{"id": "10", "resourceType": "Patient"}]' - self.assertEqual(next_left_over, '{"id": "11"') - mock_set_resource_list.assert_not_called() - mock_build_resource_type_map.assert_called_once_with(chunk_list, {}, 0) - - def test_build_resource_type_map(self): - json_file = "json/sample.json" - mapping = read_file_in_chunks(json_file, 300, "sort") - mapped_resources = { - "Patient": [0], - "Practitioner": [1, 5], - "Location": [2, 4], - "Observation": [3], - } - self.assertIsInstance(mapping, dict) - self.assertEqual(mapping, mapped_resources) - + mock_get_resource.return_value = "1" + mock_check_parent_admin_level.return_value = "3" + test_system_code = "http://terminology.hl7.org/CodeSystem/test_location-type" -if __name__ == "__main__": - unittest.main() + csv_file = csv_path + "locations/locations_full.csv" + resource_list = read_csv(csv_file) + payload = build_payload( + "locations", + resource_list, + json_path + "locations_payload.json", + None, + test_system_code, + ) + payload_obj = json.loads(payload) + self.assertEqual( + payload_obj["entry"][0]["resource"]["type"][0]["coding"][0]["system"], + test_system_code, + ) diff --git a/build/importer/tests/test_users.py b/build/importer/tests/test_users.py new file mode 100644 index 0000000..3278223 --- /dev/null +++ b/build/importer/tests/test_users.py @@ -0,0 +1,429 @@ +import json +import unittest + +from jsonschema import validate +from mock import patch + +from importer.users import (confirm_keycloak_user, confirm_practitioner, + create_user, create_user_resources) + + +class TestUsers(unittest.TestCase): + def test_uuid_generated_in_creating_user_resources_is_unique_and_repeatable(self): + users = [ + [ + "Jane", + "Doe", + "Janey", + "jdoe@example.com", + "", + "Practitioner", + "true", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ], + [ + "John", + "Doe", + "Janey", + "jodoe@example.com", + "", + "Practitioner", + "true", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ], + [ + "Janice", + "Doe", + "Jenn", + "jendoe@example.com", + "99d54e3c-c26f-4500-a7f9-3f4cb788673f", + "Supervisor", + "false", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ], + ] + + users_uuids = {} + for user_id, user in enumerate(users): + payload = create_user_resources(user[4], user) + payload_obj = json.loads(payload) + practitioner_uuid = payload_obj["entry"][0]["resource"]["id"] + group_uuid = payload_obj["entry"][1]["resource"]["id"] + practitioner_role_uuid = payload_obj["entry"][2]["resource"]["id"] + users_uuids[user_id] = [ + practitioner_uuid, + group_uuid, + practitioner_role_uuid, + ] + + # practitioner_uuid + self.assertEqual(users_uuids[0][0], users_uuids[1][0]) + self.assertNotEqual(users_uuids[1][0], users_uuids[2][0]) + + # group_uuid + self.assertEqual(users_uuids[0][1], users_uuids[1][1]) + self.assertNotEqual(users_uuids[1][1], users_uuids[2][1]) + + # practitioner_role_uuid + self.assertEqual(users_uuids[0][2], users_uuids[1][2]) + self.assertNotEqual(users_uuids[1][2], users_uuids[2][2]) + + @patch("importer.users.logging") + @patch("importer.users.handle_request") + @patch("importer.users.get_keycloak_url") + def test_create_user( + self, mock_get_keycloak_url, mock_handle_request, mock_logging + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) + mock_handle_request.return_value.status_code = 201 + mock_handle_request.return_value.headers = { + "Location": "https://keycloak.smartregister.org/auth/admin/realms" + "/example-realm/users/6cd50351-3ddb-4296-b1db" + "-aac2273e35f3" + } + mocked_user_data = ( + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "431cb523-253f-4c44-9ded-af42c55c0bbb", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + user_id = create_user(mocked_user_data) + + self.assertEqual(user_id, "6cd50351-3ddb-4296-b1db-aac2273e35f3") + mock_logging.info.assert_called_with("Setting user password") + + @patch("importer.users.handle_request") + @patch("importer.users.get_keycloak_url") + def test_create_user_already_exists( + self, mock_get_keycloak_url, mock_handle_request + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) + mock_handle_request.return_value.status_code = 409 + mocked_user_data = ( + "Jenn", + "Doe", + "Jenn", + "jendoe@example.com", + " 99d54e3c-c26f-4500-a7f9-3f4cb788673f", + "Supervisor", + "false", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + user_id = create_user(mocked_user_data) + self.assertEqual(user_id, 0) + + # Test the confirm_keycloak function + @patch("importer.users.logging") + @patch("importer.users.handle_request") + @patch("importer.users.get_keycloak_url") + def test_confirm_keycloak_user( + self, mock_get_keycloak_url, mock_handle_request, mock_logging + ): + mock_get_keycloak_url.return_value = ( + "https://keycloak.smartregister.org/auth/admin/realms/example-realm" + ) + mocked_user_data = ( + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "431cb523-253f-4c44-9ded-af42c55c0bbb", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + user_id = create_user(mocked_user_data) + self.assertEqual(user_id, 0) + + mock_response = ( + '[{"id":"6cd50351-3ddb-4296-b1db-aac2273e35f3","createdTimestamp":1710151827166,' + '"username":"Jenny","enabled":true,"totp":false,"emailVerified":false,"firstName":"Jenn",' + '"lastName":"Doe","email":"jeendoe@example.com","attributes":{"fhir_core_app_id":["demo"]},' + '"disableableCredentialTypes":[],"requiredActions":[],"notBefore":0,"access":{' + '"manageGroupMembership":true,"view":true,"mapRoles":true,"impersonate":true,' + '"manage":true}}]', + 200, + ) + mock_handle_request.return_value = mock_response + mock_json_response = json.loads(mock_response[0]) + keycloak_id = confirm_keycloak_user(mocked_user_data) + + self.assertEqual(mock_json_response[0]["username"], "Jenny") + self.assertEqual(mock_json_response[0]["email"], "jeendoe@example.com") + mock_logging.info.assert_called_with("User confirmed with id: " + keycloak_id) + + # Test confirm_practitioner function + @patch("importer.users.handle_request") + @patch("importer.users.get_base_url") + def test_confirm_practitioner_if_practitioner_uuid_not_provided( + self, mock_get_base_url, mock_handle_request + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mocked_user = ( + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + mocked_response_data = { + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + } + string_response = json.dumps(mocked_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + practitioner_exists = confirm_practitioner( + mocked_user, "431cb523-253f-4c44-9ded-af42c55c0bbb" + ) + self.assertTrue( + practitioner_exists, "Practitioner exist, linked to the provided user" + ) + + @patch("importer.users.logging") + @patch("importer.users.handle_request") + @patch("importer.users.get_base_url") + def test_confirm_practitioner_linked_keycloak_user_and_practitioner( + self, mock_get_base_url, mock_handle_request, mock_logging + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mocked_user = ( + "Jenn", + "Doe", + "Jenny", + "jeendoe@example.com", + "6cd50351-3ddb-4296-b1db-aac2273e35f3", + "Supervisor", + "TRUE", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + mocked_response_data = { + "resourceType": "Practitioner", + "identifier": [ + {"use": "official", "value": "431cb523-253f-4c44-9ded-af42c55c0bbb"}, + {"use": "secondary", "value": "6cd50351-3ddb-4296-b1db-aac2273e35f3"}, + ], + } + string_response = json.dumps(mocked_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + practitioner_exists = confirm_practitioner( + mocked_user, "6cd50351-3ddb-4296-b1db-aac2273e35f3" + ) + self.assertTrue(practitioner_exists) + self.assertEqual( + mocked_response_data["identifier"][1]["value"], + "6cd50351-3ddb-4296-b1db-aac2273e35f3", + ) + mock_logging.info.assert_called_with( + "The Keycloak user and Practitioner are linked as expected" + ) + + # Test create_user_resources function + def test_create_user_resources(self): + user = ( + "Jenn", + "Doe", + "Jenn", + "jendoe@example.com", + "99d54e3c-c26f-4500-a7f9-3f4cb788673f", + "Supervisor", + "false", + "a715b562-27f2-432a-b1ba-e57db35e0f93", + "test", + "demo", + "pa$$word", + ) + user_id = "99d54e3c-c26f-4500-a7f9-3f4cb788673f" + payload = create_user_resources(user_id, user) + payload_obj = json.loads(payload) + self.assertIsInstance(payload_obj, dict) + self.assertEqual(payload_obj["resourceType"], "Bundle") + self.assertEqual(len(payload_obj["entry"]), 3) + + resource_schema = { + "type": "object", + "properties": { + "resourceType": {"const": "Practitioner"}, + "id": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, + "identifier": { + "type": "array", + "items": { + "type": "object", + "properties": { + "use": { + "type": "string", + "enum": ["official", "secondary"], + }, + "type": { + "type": "object", + "properties": { + "coding": { + "type": "array", + "items": { + "type": "object", + "properties": { + "system": { + "const": "http://hl7.org/fhir/identifier-type" + }, + "code": {"const": "KUID"}, + "display": { + "const": "Keycloak user ID" + }, + }, + }, + }, + "text": {"const": "Keycloak user ID"}, + }, + }, + "value": {"const": "99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, + }, + }, + }, + "name": { + "type": "array", + "items": { + "type": "object", + "properties": { + "use": {"const": "official"}, + "family": {"const": "Doe"}, + "given": {"type": "array", "items": {"type": "string"}}, + }, + }, + }, + }, + "required": ["resourceType", "id", "identifier", "name"], + } + validate(payload_obj["entry"][0]["resource"], resource_schema) + + request_schema = { + "type": "object", + "properties": { + "method": {"const": "PUT"}, + "url": {"const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f"}, + "ifMatch": {"const": "1"}, + }, + } + validate(payload_obj["entry"][0]["request"], request_schema) + + resource_schema = { + "type": "object", + "properties": { + "resourceType": {"const": "Group"}, + "id": {"const": "0de5f541-65ca-5504-ad6b-9b386e5f8810"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "name": {"const": "Jenn Doe"}, + "member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity": { + "type": "object", + "properties": { + "reference": { + "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" + } + }, + } + }, + }, + }, + }, + "required": ["resourceType", "id", "identifier", "name", "member"], + } + validate(payload_obj["entry"][1]["resource"], resource_schema) + + request_schema = { + "type": "object", + "properties": { + "method": {"const": "PUT"}, + "url": {"const": "Group/0de5f541-65ca-5504-ad6b-9b386e5f8810"}, + "ifMatch": {"const": "1"}, + }, + } + validate(payload_obj["entry"][1]["request"], request_schema) + + resource_schema = { + "type": "object", + "properties": { + "resourceType": {"const": "PractitionerRole"}, + "id": {"const": "f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3"}, + "identifier": {"type": "array", "items": {"type": "object"}}, + "practitioner": { + "type": "object", + "properties": { + "reference": { + "const": "Practitioner/99d54e3c-c26f-4500-a7f9-3f4cb788673f" + }, + "display": {"const": "Jenn Doe"}, + }, + }, + "code": { + "type": "object", + "properties": { + "coding": { + "type": "array", + "items": { + "type": "object", + "properties": { + "system": {"const": "http://snomed.info/sct"}, + "code": {"const": "236321002"}, + "display": {"const": "Supervisor (occupation)"}, + }, + }, + } + }, + }, + }, + "required": ["resourceType", "id", "identifier", "practitioner", "code"], + } + validate(payload_obj["entry"][2]["resource"], resource_schema) + + request_schema = { + "type": "object", + "properties": { + "method": {"const": "PUT"}, + "url": { + "const": "PractitionerRole/f08e0373-932e-5bcb-bdf2-0c28a3c8fdd3" + }, + "ifMatch": {"const": "1"}, + }, + } + validate(payload_obj["entry"][2]["request"], request_schema) diff --git a/build/importer/tests/test_utils.py b/build/importer/tests/test_utils.py new file mode 100644 index 0000000..4a7a801 --- /dev/null +++ b/build/importer/tests/test_utils.py @@ -0,0 +1,196 @@ +import json +import pathlib +import unittest + +from mock import patch + +from importer.utils import (export_resources_to_csv, read_csv, + read_file_in_chunks, split_chunk, write_csv) + +dir_path = str(pathlib.Path(__file__).parent.resolve()) + + +class TestUtils(unittest.TestCase): + def test_read_csv(self): + csv_file = dir_path + "/../csv/users.csv" + records = read_csv(csv_file) + self.assertIsInstance(records, list) + self.assertEqual(len(records), 3) + + def test_write_csv(self): + self.test_data = [ + [ + "e2e-mom", + "True", + "update", + "caffe509-ae56-4d42-945e-7b4c161723d1", + "d93ae7c3-73c0-43d1-9046-425a3466ecec", + ], + [ + "e2e-skate", + "True", + "update", + "2d4feac9-9ab5-4585-9b33-e5abd14ceb0f", + "58605ed8-7217-4bf3-8122-229b6f47fa64", + ], + ] + self.test_resource_type = "test_organization" + self.test_fieldnames = ["name", "active", "method", "id", "identifier"] + csv_file = write_csv( + self.test_data, self.test_resource_type, self.test_fieldnames + ) + csv_content = read_csv(csv_file) + self.assertEqual(csv_content, self.test_data) + + @patch("importer.utils.write_csv") + @patch("importer.utils.handle_request") + @patch("importer.utils.get_base_url") + def test_export_resource_to_csv( + self, mock_get_base_url, mock_handle_request, mock_write_csv + ): + mock_get_base_url.return_value = "https://example.smartregister.org/fhir" + mock_response_data = { + "entry": [ + { + "resource": { + "name": "City1", + "status": "active", + "id": "ba787982-b973-4bd5-854e-eacbe161e297", + "identifier": [ + {"value": "ba787 982-b973-4bd5-854e-eacbe161e297"} + ], + "partOf": { + "display": "test location-1", + "reference": "Location/18fcbc2e-4240-4a84-a270" + "-7a444523d7b6", + }, + "type": [ + {"coding": [{"display": "Jurisdiction", "code": "jdn"}]} + ], + "physicalType": { + "coding": [{"display": "Jurisdiction", "code": "jdn"}] + }, + } + } + ] + } + string_response = json.dumps(mock_response_data) + mock_response = (string_response, 200) + mock_handle_request.return_value = mock_response + test_data = [ + [ + "City1", + "active", + "update", + "ba787982-b973-4bd5-854e-eacbe161e297", + "ba787 982-b973-4bd5-854e-eacbe161e297", + "test location-1", + "18fcbc2e-4240-4a84-a270-7a444523d7b6", + "Jurisdiction", + "jdn", + "Jurisdiction", + "jdn", + ] + ] + test_elements = [ + "name", + "status", + "method", + "id", + "identifier", + "parentName", + "parentID", + "type", + "typeCode", + "physicalType", + "physicalTypeCode", + ] + resource_type = "Location" + export_resources_to_csv("Location", "_lastUpdated", "gt2023-08-01", 1) + mock_write_csv.assert_called_once_with(test_data, resource_type, test_elements) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_first_chunk_less_than_size( + self, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"}' + next_left_over = split_chunk(chunk, "", 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_middle_chunk_less_than_size( + self, mock_set_resource_list + ): + chunk = ' "resourceType": "Patient"}' + left_over_chunk = '{"id": "10",' + next_left_over = split_chunk(chunk, left_over_chunk, 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_last_chunk_less_than_size( + self, mock_set_resource_list + ): + left_over_chunk = '{"id": "10", "resourceType": "Patient"}]' + next_left_over = split_chunk("", left_over_chunk, 50, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, "-") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_first_chunk_greater_than_size( + self, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' + next_left_over = split_chunk(chunk, "", 40, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11", "resourceType":') + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_middle_chunk_greater_than_size( + self, mock_set_resource_list + ): + chunk = ': "Task"},{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType":' + left_over_chunk = '{"id": "09", "resourceType"' + next_left_over = split_chunk(chunk, left_over_chunk, 80, {}, "direct") + chunk_list = '[{"id": "09", "resourceType": "Task"},{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11", "resourceType":') + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + def test_split_chunk_direct_sync_last_chunk_greater_than_size( + self, mock_set_resource_list + ): + left_over_chunk = '{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' + next_left_over = split_chunk("", left_over_chunk, 43, {}, "direct") + chunk_list = '[{"id": "10", "resourceType": "Patient"},{"id": "11", "resourceType": "Task"}]' + self.assertEqual(next_left_over, "") + mock_set_resource_list.assert_called_once_with(chunk_list) + + @patch("importer.utils.set_resource_list") + @patch("importer.utils.build_resource_type_map") + def test_split_chunk_sort_sync_first_chunk_less_than_size( + self, mock_build_resource_type_map, mock_set_resource_list + ): + chunk = '[{"id": "10", "resourceType": "Patient"},{"id": "11"' + next_left_over = split_chunk(chunk, "", 50, {}, "sort") + chunk_list = '[{"id": "10", "resourceType": "Patient"}]' + self.assertEqual(next_left_over, '{"id": "11"') + mock_set_resource_list.assert_not_called() + mock_build_resource_type_map.assert_called_once_with(chunk_list, {}, 0) + + def test_build_resource_type_map(self): + json_file = dir_path + "/json/sample.json" + mapping = read_file_in_chunks(json_file, 300, "sort") + mapped_resources = { + "Patient": [0], + "Practitioner": [1, 5], + "Location": [2, 4], + "Observation": [3], + } + self.assertIsInstance(mapping, dict) + self.assertEqual(mapping, mapped_resources) diff --git a/build/importer/utils/location_process.py b/build/importer/utils/location_process.py deleted file mode 100644 index 9d1e589..0000000 --- a/build/importer/utils/location_process.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -preprocess a csv of location records, group them by their admin levels -""" - - -class LocationNode: - def __init__(self, raw_record=None, parent_node=None, children=None): - if children is None: - children = [] - self.parent = parent_node - self.children = children - self.raw_record = raw_record - - @property - def admin_level(self): - try: - return self.raw_record[8] - except: - return None - - @property - def location_id(self): - try: - return self.raw_record[3] - except: - return None - - def __repr__(self): - return f"" - - -def group_by_admin_level(csv_records): - location_node_store = {} - for record in csv_records: - location_id, parent_id = record[3], record[5] - this_record_node = location_node_store.get(location_id) - if this_record_node: - # assume tombstone - this_record_node.raw_record = record - else: - this_record_node = LocationNode(record) - location_node_store[location_id] = this_record_node - # see if this parentNode exists in the nodeStore - if parent_id: - this_location_parent_node = location_node_store.get(parent_id) - if this_location_parent_node is not None: - pass - else: - # create a tombstone - this_location_parent_node = LocationNode() - location_node_store[parent_id] = this_location_parent_node - this_location_parent_node.children.append(this_record_node) - this_record_node.parent = this_location_parent_node - return location_node_store - - -def get_node_children(parents): - children = [] - for node in parents: - children.extend(node.children) - return children - - -def get_next_admin_level(node_map_store: dict): - """generator function that yields the next group of admin level locations""" - # start by getting the parent locations. i.e. locations that do not have parent - parent_nodes = [] - for node in node_map_store.values(): - if node.parent is None and node.raw_record or node.parent and node.parent.raw_record is None: - parent_nodes.append(node) - yield parent_nodes - - fully_traversed = False - while not fully_traversed: - children_at_this_level = get_node_children(parent_nodes) - if len(children_at_this_level) == 0: - fully_traversed = True - else: - parent_nodes = children_at_this_level - yield children_at_this_level - - -def process_locations(csv_records): - nodes_map = group_by_admin_level(csv_records) - for batch in get_next_admin_level(nodes_map): - batch_raw_records = [] - for node in batch: - batch_raw_records.append(node.raw_record) - yield batch_raw_records - -# TODO - validate based on adminlevel and the generated groupings based on the parentIds \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js index 17aef4e..d456aee 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,4 +1,23 @@ -global.console = { - ...console, - log: jest.fn(), -}; +// setupTests.js +const { RedisContainer } = require('@testcontainers/redis') + +let redisContainer; + +beforeAll(async () => { + // Start Redis container + redisContainer = await new RedisContainer('redis') + .withExposedPorts(6379) + .start(); + + // Set environment variables or global variables to be used in tests + process.env.REDIS_HOST = redisContainer.getHost(); + process.env.REDIS_CONNECTION_URL=redisContainer.getConnectionUrl() + process.env.REDIS_PORT = redisContainer.getMappedPort(6379); + console.log('Containers are started and ready for testing...'); +}); + +afterAll(async () => { + // Stop containers after all tests are done + await redisContainer?.stop(); + console.log('Containers have been stopped.'); +}); diff --git a/package.json b/package.json index 7cd0fa4..ebbe30b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "cookie-parser": "^1.4.6", "dotenv": "^16.0.0", "express": "^4.17.3", - "express-session": "^1.17.2", + "express-session": "^1.18.1", "helmet": "^5.0.2", "ioredis": "^5.0.6", "morgan": "^1.10.0", @@ -54,6 +54,7 @@ "winston": "^3.6.0" }, "devDependencies": { + "@testcontainers/redis": "^10.11.0", "@types/adm-zip": "^0.5.5", "@types/compression": "^1.7.2", "@types/connect-redis": "^0.0.18", @@ -87,7 +88,7 @@ "eslint-plugin-sonarjs": "^0.12.0", "husky": "^7.0.4", "ioredis-mock": "^8.2.2", - "jest": "^27.5.1", + "jest": "29.7.0", "lint-staged": "^12.3.4", "mockdate": "^3.0.5", "nock": "^13.2.4", @@ -96,7 +97,11 @@ "prettier": "^2.5.1", "rimraf": "^3.0.1", "supertest": "^6.2.2", - "ts-jest": "^27.1.3" + "testcontainers": "^10.11.0", + "ts-jest": "^29.2.4" }, - "packageManager": "yarn@4.5.3" + "packageManager": "yarn@4.5.3", + "engines": { + "node": ">=20.16" + } } diff --git a/src/app/dollar-imports/job.ts b/src/app/dollar-imports/helpers/job.ts similarity index 56% rename from src/app/dollar-imports/job.ts rename to src/app/dollar-imports/helpers/job.ts index e49a658..61fc0d2 100644 --- a/src/app/dollar-imports/job.ts +++ b/src/app/dollar-imports/helpers/job.ts @@ -1,12 +1,30 @@ import { spawn } from 'child_process'; import { Job as BullJob } from 'bull'; -import { UploadWorkflowTypes, dependencyGraph, importerSourceFilePath } from './utils'; - -export function getImportScriptArgs(workflowType: string, filePath: string) { +import { JobData, UploadWorkflowTypes, dependencyGraph, importerSourceFilePath } from './utils'; +import { + EXPRESS_OPENSRP_CLIENT_ID, + EXPRESS_OPENSRP_SERVER_URL, + EXPRESS_OPENSRP_CLIENT_SECRET, + EXPRESS_PYTHON_INTERPRETER_PATH, +} from '../../../configs/envs'; +import { realm, keycloakBaseUrl } from '../../helpers/utils'; +import { winstonLogger } from '../../../configs/winston'; + +export function getImportScriptArgs(jobData: JobData) { + const { workflowType, filePath: rawFilePath, productListId, inventoryListId } = jobData; + const filePath = `"${rawFilePath}"`; const commonFlags = ['--log_level', 'info']; switch (workflowType) { case UploadWorkflowTypes.Locations: - return ['--csv_file', filePath, '--resource_type', 'locations', ...commonFlags]; + return [ + '--csv_file', + filePath, + '--resource_type', + 'locations', + '--location_type_coding_system', + 'http://smartregister.org/CodeSystem/eusm-service-point-type', + ...commonFlags, + ]; case UploadWorkflowTypes.Users: return ['--csv_file', filePath, '--resource_type', 'users', ...commonFlags]; case UploadWorkflowTypes.CareTeams: @@ -17,10 +35,19 @@ export function getImportScriptArgs(workflowType: string, filePath: string) { return ['--csv_file', filePath, '--assign', 'users-organizations', ...commonFlags]; case UploadWorkflowTypes.Organizations: return ['--csv_file', filePath, '--resource_type', 'organizations', ...commonFlags]; + // invariant: at this point the product list and inventory list id are defined case UploadWorkflowTypes.Products: - return ['--csv_file', filePath, '--setup', 'products', ...commonFlags]; + return ['--csv_file', filePath, '--setup', 'products', '--list_resource_id', `${productListId}`, ...commonFlags]; case UploadWorkflowTypes.Inventories: - return ['--csv_file', filePath, '--setup', 'inventories', ...commonFlags]; + return [ + '--csv_file', + filePath, + '--setup', + 'inventories', + '--list_resource_id', + `${inventoryListId}`, + ...commonFlags, + ]; default: return []; } @@ -45,20 +72,24 @@ export class Job { job: BullJob; + jobData: JobData; + preconditionPassed = false; dateStarted: number; - constructor(job: BullJob) { - const options = job.data; + constructor(job: BullJob) { + const jobData = job.data; + this.jobData = jobData; this.job = job; - this.workflowType = options.workflowType; - this.csv_file = options.filePath; + this.workflowType = jobData.workflowType; + this.csv_file = jobData.filePath; this.startDate = Date.now(); - this.workflowId = options.workflowId; + this.workflowId = jobData.workflowId; } async precondition() { + winstonLogger.info(`job ${this.jobData.workflowId}: Starting precondition check`); const allJobs = await this.job.queue.getJobs([]); // find jobs that are related with this upload. const thisJobUploadId = this.workflowId.split('_')[0]; @@ -81,7 +112,9 @@ export class Job { const failedStates = preceedingJobsStatus.filter((status) => status.jobState === 'failed'); if (failedStates.length) { - throw new Error(`Preceeding job of type ${failedStates.map((state) => state.workflowType).join()} failed`); + const failedMessage = `Preceeding job of type ${failedStates.map((state) => state.workflowType).join()} failed`; + winstonLogger.error(`job ${this.jobData.workflowId}: ${failedMessage}`); + throw new Error(failedMessage); } const incompleteJobs = preceedingJobsStatus.filter((status) => status.jobState !== 'completed'); @@ -89,14 +122,17 @@ export class Job { this.preconditionPassed = false; // keep running loop } else { + winstonLogger.info(`job ${this.jobData.workflowId}: precondition passed`); this.preconditionPassed = true; // pass precondition } } async asyncDoTask() { + winstonLogger.info(`job ${this.jobData.workflowId}: task start instruction`); return new Promise((resolve, reject) => { const doTaskIntervalId = setInterval(async () => { + winstonLogger.info(`job ${this.jobData.workflowId}: invoking precondition check`); if (this.preconditionPassed) { clearInterval(doTaskIntervalId); try { @@ -108,6 +144,8 @@ export class Job { try { await this.precondition(); } catch (error) { + winstonLogger.info(`job ${this.jobData.workflowId}: precondition failed with ${error}`); + clearInterval(doTaskIntervalId); reject(error); } } @@ -116,14 +154,31 @@ export class Job { } run() { - const command = 'python3'; - const scriptArgs = ['main.py', ...getImportScriptArgs(this.workflowType, this.csv_file)]; + const command = EXPRESS_PYTHON_INTERPRETER_PATH; + const scriptArgs = ['main.py', ...getImportScriptArgs(this.jobData)]; return new Promise((resolve, reject) => { // Append the file path to the arguments list + const cwdEnv = { + client_id: EXPRESS_OPENSRP_CLIENT_ID, + client_secret: EXPRESS_OPENSRP_CLIENT_SECRET, + fhir_base_url: EXPRESS_OPENSRP_SERVER_URL, + keycloak_url: keycloakBaseUrl, + realm, + access_token: this.jobData.accessToken, + refresh_token: this.jobData.refreshToken, + // OAUTHLIB_INSECURE_TRANSPORT: '1', + }; + + winstonLogger.info(`job ${this.jobData.workflowId}: Importer script started with: ${[command, scriptArgs]}`); + // Spawn the child process - const childProcess = spawn(command, scriptArgs, { cwd: importerSourceFilePath }); + const childProcess = spawn(command, scriptArgs, { + cwd: importerSourceFilePath, + env: cwdEnv, + shell: true, + }); let stdoutData = ''; let stderrData = ''; @@ -141,14 +196,18 @@ export class Job { // Handle process completion childProcess.on('close', (code: number) => { if (code === 0) { + winstonLogger.info(`job ${this.jobData.workflowId}: Importer script ran to completion`); resolve({ stdout: stdoutData, stderr: stderrData }); } else { - reject(new Error(JSON.stringify({ stdout: stdoutData, stderr: stderrData }))); + const errorResponse = JSON.stringify({ stdout: stdoutData, stderr: stderrData }); + winstonLogger.error(`job ${this.jobData.workflowId}: Importer script failed with error: ${errorResponse} `); + reject(new Error(errorResponse)); } }); // Handle errors childProcess.on('error', (_: Error) => { + winstonLogger.error(`job ${this.jobData.workflowId}: Child process failed with ${_}`); reject(new Error(JSON.stringify({ stdout: stdoutData, stderr: stderrData }))); }); }); diff --git a/src/app/dollar-imports/helpers/middleware.ts b/src/app/dollar-imports/helpers/middleware.ts new file mode 100644 index 0000000..ba9f855 --- /dev/null +++ b/src/app/dollar-imports/helpers/middleware.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from 'express'; +import { getImportQueue } from './queue'; +import { getRedisClient } from '../../helpers/redisClient'; +import { winstonLogger } from '../../../configs/winston'; + +// Ensures that requests are authenticated +export const sessionChecker = (req: Request, res: Response, next: NextFunction) => { + if (!req.session.preloadedState) { + winstonLogger.error('No session found. User not authorized'); + return res.status(401).json({ error: 'Not authorized' }); + } + + next(); +}; + +/** Checks that redis is enabled for the data import */ +export const redisRequiredMiddleWare = (_: Request, res: Response, next: NextFunction) => { + const redisClient = getRedisClient(); + const importQ = getImportQueue(); + if (!redisClient || !importQ) { + winstonLogger.info('No redis connection found.'); + return res.status(500).json({ error: 'No redis connection found. Redis is required to enable this feature.' }); + } + next(); +}; + +export const importRouterErrorhandler = (err: Error, req: Request, res: Response, _: NextFunction) => { + // log error + winstonLogger.error(err.message); + res.status(500).send(err.message); +}; diff --git a/src/app/dollar-imports/queue.ts b/src/app/dollar-imports/helpers/queue.ts similarity index 67% rename from src/app/dollar-imports/queue.ts rename to src/app/dollar-imports/helpers/queue.ts index 12b2d19..d0cc1a7 100644 --- a/src/app/dollar-imports/queue.ts +++ b/src/app/dollar-imports/helpers/queue.ts @@ -1,11 +1,13 @@ import type Bull from 'bull'; -import { getRedisClient, redisIsConfigured } from '../helpers/redisClient'; +import { getRedisClient, redisIsConfigured } from '../../helpers/redisClient'; import { Job } from './job'; +import { EXPRESS_BULK_UPLOAD_REDIS_QUEUE } from '../../../configs/envs'; +import { BULK_UPLOAD_CONCURRENT_JOBS } from '../../../constants'; // eslint-disable-next-line @typescript-eslint/no-var-requires const Queue = require('bull'); -export const importQName = 'fhir-import-queue'; +export const importQName = EXPRESS_BULK_UPLOAD_REDIS_QUEUE; let importQ: Bull.Queue | undefined; export type BullQ = Bull.Queue; @@ -23,7 +25,7 @@ export function getImportQueue() { // Process jobs from the queue importQ - ?.process((jobArgs: Bull.Job) => { + ?.process(BULK_UPLOAD_CONCURRENT_JOBS, (jobArgs: Bull.Job) => { const jobInstance = new Job(jobArgs); return jobInstance.asyncDoTask().catch((err) => { throw err; diff --git a/src/app/dollar-imports/utils.ts b/src/app/dollar-imports/helpers/utils.ts similarity index 74% rename from src/app/dollar-imports/utils.ts rename to src/app/dollar-imports/helpers/utils.ts index 8e45262..0c9243d 100644 --- a/src/app/dollar-imports/utils.ts +++ b/src/app/dollar-imports/helpers/utils.ts @@ -1,8 +1,9 @@ import path from 'path'; import { Job as BullJob } from 'bull'; -export const importerSourceFilePath = path.resolve(__dirname, '../../../importer'); +export const importerSourceFilePath = path.resolve(__dirname, '../../../../importer'); export const templatesFolder = path.resolve(importerSourceFilePath, 'csv'); +export const StrPartsSep = '-'; export enum UploadWorkflowTypes { Locations = 'locations', @@ -15,6 +16,17 @@ export enum UploadWorkflowTypes { userToOrganizationAssignment = 'userToOrganizationAssignment', } +export interface JobData { + workflowType: UploadWorkflowTypes; + filePath: string; + workflowId: string; + author: string; + accessToken: string; + refreshToken: string; + productListId?: string; + inventoryListId?: string; +} + export const resourceUploadCodeToTemplatePathLookup = { [UploadWorkflowTypes.Users]: path.resolve(templatesFolder, 'users.csv'), [UploadWorkflowTypes.Organizations]: path.resolve(templatesFolder, 'organizations/organizations_full.csv'), @@ -53,7 +65,9 @@ export async function parseJobResponse(job: BullJob) { const status = await job.getState(); const jobData = job.data; const { workflowType, filePath, author } = jobData; - const filename = filePath ? path.posix.basename(filePath) : ''; + const internalFilename = filePath ? path.posix.basename(filePath) : ''; + const splitFileName = internalFilename.split(StrPartsSep); + const normalizedFilename = splitFileName[splitFileName.length - 1]; let statusReason; if (status === 'failed') { @@ -74,7 +88,8 @@ export async function parseJobResponse(job: BullJob) { dateStarted: job.processedOn, dateEnded: job.finishedOn, statusReason, - filename, + filename: normalizedFilename, + internalFilename, author, }; } @@ -88,3 +103,18 @@ export const dependencyGraph: DependencyGraph = { [UploadWorkflowTypes.userToOrganizationAssignment]: [UploadWorkflowTypes.Users, UploadWorkflowTypes.Organizations], [UploadWorkflowTypes.Inventories]: [UploadWorkflowTypes.Products], }; + +/** validates workflow arguments + * + * @param args - args for initiating a bull job. + */ +export function validateWorkflowArgs(args: JobData[]) { + for (const arg of args) { + if (arg.workflowType === UploadWorkflowTypes.Products && !arg.productListId) { + throw new Error('Product list id for the product upload workflow was not provided'); + } + if (arg.workflowType === UploadWorkflowTypes.Inventories && !arg.inventoryListId) { + throw new Error('inventory list id for the inventory upload workflow was not provided'); + } + } +} diff --git a/src/app/dollar-imports/importerConfigWriter.ts b/src/app/dollar-imports/importerConfigWriter.ts deleted file mode 100644 index 04938b0..0000000 --- a/src/app/dollar-imports/importerConfigWriter.ts +++ /dev/null @@ -1,31 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import { importerSourceFilePath } from './utils'; -import { - EXPRESS_OPENSRP_CLIENT_SECRET, - EXPRESS_OPENSRP_CLIENT_ID, - EXPRESS_OPENSRP_ACCESS_TOKEN_URL, - EXPRESS_OPENSRP_SERVER_URL, -} from '../../configs/envs'; -import { parseKeycloakUrl } from '../helpers/utils'; - -/** write authentication configs for the importer script */ -export async function writeImporterScriptConfig(configContents: string) { - const configFilePath = path.resolve(importerSourceFilePath, 'config/config.py'); - await fs.writeFile(configFilePath, configContents); -} - -export function generateImporterSCriptConfig(accessToken?: string, refreshToken?: string) { - const { realm, keycloakBaseUrl } = parseKeycloakUrl(EXPRESS_OPENSRP_ACCESS_TOKEN_URL); - let outputString = ''; - if (EXPRESS_OPENSRP_CLIENT_ID) { - outputString += `client_id = "${EXPRESS_OPENSRP_CLIENT_ID}"\n`; - } - if (EXPRESS_OPENSRP_CLIENT_SECRET) outputString += `client_secret = "${EXPRESS_OPENSRP_CLIENT_SECRET}"\n`; - if (realm) outputString += `realm = "${realm}"\n`; - if (accessToken) outputString += `access_token = "${accessToken}"\n`; - if (refreshToken) outputString += `refresh_token = "${refreshToken}"\n`; - if (keycloakBaseUrl) outputString += `keycloak_url = "${keycloakBaseUrl}"\n`; - if (EXPRESS_OPENSRP_SERVER_URL) outputString += `fhir_base_url = "${EXPRESS_OPENSRP_SERVER_URL}"\n`; - return outputString; -} diff --git a/src/app/dollar-imports/index.ts b/src/app/dollar-imports/index.ts index 9fc417f..0f85539 100644 --- a/src/app/dollar-imports/index.ts +++ b/src/app/dollar-imports/index.ts @@ -6,9 +6,17 @@ import { mkdir } from 'fs/promises'; import AdmZip from 'adm-zip'; import { randomUUID } from 'crypto'; import { Job as BullJob } from 'bull'; -import { getImportQueue, BullQ } from './queue'; -import { getAllTemplateFilePaths, getTemplateFilePath, parseJobResponse, UploadWorkflowTypes } from './utils'; -import { redisRequiredMiddleWare, sessionChecker, writeImporterConfigMiddleware } from './middleware'; +import { getImportQueue, BullQ } from './helpers/queue'; +import { + getAllTemplateFilePaths, + getTemplateFilePath, + JobData, + parseJobResponse, + StrPartsSep, + UploadWorkflowTypes, + validateWorkflowArgs, +} from './helpers/utils'; +import { importRouterErrorhandler, redisRequiredMiddleWare, sessionChecker } from './helpers/middleware'; import { A_DAY } from '../../constants'; import { EXPRESS_TEMP_CSV_FILE_STORAGE } from '../../configs/envs'; @@ -17,7 +25,7 @@ const importQ = getImportQueue() as BullQ; const storage = multer.diskStorage({ async destination(_, __, cb) { - const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const uniqueSuffix = `${Date.now()}`; const folderPath = `${EXPRESS_TEMP_CSV_FILE_STORAGE}/${uniqueSuffix}`; if (!fs.existsSync(folderPath)) { await mkdir(folderPath, { recursive: true }); @@ -25,12 +33,14 @@ const storage = multer.diskStorage({ cb(null, folderPath); }, filename(req, file, cb) { - cb(null, file.originalname); + const newFileName = `${Math.round(Math.random() * 1e9)}${StrPartsSep}${file.originalname}`; + cb(null, newFileName); }, }); const upload = multer({ storage }); +importerRouter.use(sessionChecker); importerRouter.use(redisRequiredMiddleWare); importerRouter.get('/', sessionChecker, async (req, res) => { @@ -45,7 +55,7 @@ importerRouter.get('/', sessionChecker, async (req, res) => { ); }); -importerRouter.get('/templates', sessionChecker, async (req, res) => { +importerRouter.get('/templates', async (req, res) => { const uploadCodeTemplate = req.query.resourceTemplate; if (typeof uploadCodeTemplate === 'string') { @@ -77,7 +87,7 @@ importerRouter.get('/templates', sessionChecker, async (req, res) => { } }); -importerRouter.get('/:slug', sessionChecker, async (req, res) => { +importerRouter.get('/:slug', async (req, res) => { const wkFlowId = req.params.slug; const job = await importQ.getJob(wkFlowId); @@ -86,26 +96,40 @@ importerRouter.get('/:slug', sessionChecker, async (req, res) => { res.json(rtnVal); return; } - res.status(401).send({ message: `Workflow with id ${wkFlowId} was not found` }); + res.status(404).send({ message: `Workflow with id ${wkFlowId} was not found` }); }); -importerRouter.post('/', sessionChecker, writeImporterConfigMiddleware, upload.any(), async (req, res) => { +importerRouter.post('/', upload.any(), async (req, res, next) => { const files = req.files as Express.Multer.File[] | undefined; const user = req.session.preloadedState?.session?.user?.username; + const { productListId, inventoryListId } = req.query; + const uploadId = randomUUID(); - const workflowArgs = files?.map((file) => { - const jobId = `${uploadId}_${file.fieldname}`; - return { - workflowType: file.fieldname, - filePath: file.path, - workflowId: jobId, - author: user, - }; - }); + const workflowArgs = + files?.map((file) => { + const jobId = `${uploadId}_${file.fieldname}`; + return { + workflowType: file.fieldname, + filePath: file.path, + workflowId: jobId, + author: user, + accessToken: req.session.preloadedState?.session?.extraData?.oAuth2Data?.access_token, + refreshToken: req.session.preloadedState?.session?.extraData?.oAuth2Data?.refresh_token, + productListId, + inventoryListId, + } as JobData; + }) ?? []; + + try { + validateWorkflowArgs(workflowArgs); + } catch (err) { + next(err); + return; + } const addedJobs: BullJob[] = await Promise.all( - (workflowArgs ?? []).map((arg) => + workflowArgs.map((arg) => importQ.add(arg, { jobId: arg.workflowId, removeOnComplete: { age: A_DAY }, @@ -124,4 +148,6 @@ importerRouter.post('/', sessionChecker, writeImporterConfigMiddleware, upload.a ); }); +importerRouter.use(importRouterErrorhandler); + export { importerRouter }; diff --git a/src/app/dollar-imports/middleware.ts b/src/app/dollar-imports/middleware.ts deleted file mode 100644 index 04f3462..0000000 --- a/src/app/dollar-imports/middleware.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { getImportQueue } from './queue'; -import { generateImporterSCriptConfig, writeImporterScriptConfig } from './importerConfigWriter'; -import { getRedisClient } from '../helpers/redisClient'; - -// Ensures that requests are authenticated -export const sessionChecker = (req: Request, res: Response, next: NextFunction) => { - if (!req.session.preloadedState) { - return res.json({ error: 'Not authorized' }); - } - - next(); -}; - -/** Checks that redis is enabled for the data import */ -export const redisRequiredMiddleWare = (_: Request, res: Response, next: NextFunction) => { - const redisClient = getRedisClient(); - const importQ = getImportQueue(); - if (!redisClient || !importQ) { - return res.json({ error: 'No redis connection found. Redis is required to enable this feature.' }); - } - next(); -}; - -/** A middleware that writes the bulk upload importer config. */ -export const writeImporterConfigMiddleware = async (req: Request, __: Response, next: NextFunction) => { - const accessToken = req.session.preloadedState?.session?.extraData?.oAuth2Data?.access_token; - const refreshToken = req.session.preloadedState?.session?.extraData?.oAuth2Data?.refresh_token; - const importerConfig = generateImporterSCriptConfig(accessToken, refreshToken); - await writeImporterScriptConfig(importerConfig); - next(); -}; diff --git a/src/app/dollar-imports/tests/dollar-imports.test.ts b/src/app/dollar-imports/tests/dollar-imports.test.ts new file mode 100644 index 0000000..d6f33d2 --- /dev/null +++ b/src/app/dollar-imports/tests/dollar-imports.test.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import request from 'supertest'; +import express from 'express'; +import path from 'path'; +import app from '../..'; +import { mockSession } from './fixtures/fixtures'; + +const spawnMock = jest.fn(); + +jest.mock('child_process', () => { + return { + ...jest.requireActual('child_process'), + spawn: function mySpawn(...args: any) { + spawnMock(...args); + return { + stdout: { + on: jest.fn(), + }, + stderr: { + on: jest.fn(), + }, + on: jest.fn(), + }; + }, + }; +}); + +jest.mock('crypto', () => ({ + ...jest.requireActual('crypto'), + randomUUID: jest.fn(() => 'mocked-uuid'), +})); + +jest.mock('../../../configs/envs'); + +describe('dollar import Unauthenticated', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('unauthorized access', async () => { + const response = await request(app).get('/$import/'); + expect(response.statusCode).toEqual(401); + expect(response.text).toEqual('{"error":"Not authorized"}'); + }); +}); + +jest.mock('../../../configs/envs', () => ({ + __esModule: true, + ...{ ...jest.requireActual('../../../configs/envs') }, +})); + +describe('dollar import authenticated', () => { + beforeEach(() => {}); + + afterEach(async () => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('needs redis connection to work', async () => { + jest.resetModules(); + jest.mock('../../../configs/envs', () => ({ + ...jest.requireActual('../../../configs/envs'), + EXPRESS_REDIS_STAND_ALONE_URL: undefined, + })); + + const { importerRouter } = await import('../index'); + const app2 = express(); + app2.use((req, res, next) => { + req.session = mockSession as any; + next(); + }); + app2.use(importerRouter); + + const response = await request(app2).get('/'); + expect(response.statusCode).toEqual(500); + expect(response.text).toEqual('{"error":"No redis connection found. Redis is required to enable this feature."}'); + }); + + it('works correctly for get', async () => { + jest.resetModules(); + jest.mock('../../../configs/envs', () => ({ + ...jest.requireActual('../../../configs/envs'), + EXPRESS_REDIS_STAND_ALONE_URL: process.env.REDIS_CONNECTION_URL, + })); + + const { importerRouter } = await import('../index'); + const app2 = express(); + app2.use((req, res, next) => { + req.session = mockSession as any; + next(); + }); + app2.use(importerRouter); + + let response = await request(app2).get('/'); + expect(response.statusCode).toEqual(200); + expect(response.text).toEqual('[]'); + + response = await request(app2).get('/nonExistent'); + expect(response.statusCode).toEqual(404); + expect(response.text).toEqual('{"message":"Workflow with id nonExistent was not found"}'); + }); + + it('can post jobs.', async () => { + jest.resetModules(); + const sampleCsv = `${path.resolve(__dirname, 'fixtures/sample.csv')}`; + const actualSetInterval = global.setInterval; + jest.spyOn(global, 'setInterval').mockImplementation((callback, ms) => { + if (ms === 1000) { + return actualSetInterval(callback, 0); + } + return actualSetInterval(callback, ms); + }); + + jest.mock('../../../configs/envs', () => ({ + ...jest.requireActual('../../../configs/envs'), + EXPRESS_REDIS_STAND_ALONE_URL: process.env.REDIS_CONNECTION_URL, + })); + + const { importerRouter } = await import('../index'); + const app2 = express(); + app2.use((req, res, next) => { + req.session = mockSession as any; + next(); + }); + app2.use(importerRouter); + + const response = await request(app2).post('/').attach('users', sampleCsv); + expect(response.statusCode).toEqual(200); + const data = JSON.parse(response.text); + expect(data).toMatchObject([ + { + workflowId: 'mocked-uuid_users', + status: 'active', + workflowType: 'users', + filename: 'sample.csv', + internalFilename: expect.any(String), + author: 'demo', + }, + ]); + }, 10000); +}); diff --git a/src/app/dollar-imports/tests/fixtures/fixtures.ts b/src/app/dollar-imports/tests/fixtures/fixtures.ts new file mode 100644 index 0000000..720de2b --- /dev/null +++ b/src/app/dollar-imports/tests/fixtures/fixtures.ts @@ -0,0 +1,250 @@ +export const mockSession = { + cookie: { originalMaxAge: 1800000, expires: '2024-08-16T07:53:58.905Z', secure: false, httpOnly: true, path: '/' }, + __lastAccess: 1723793038905, + preloadedState: { + gatekeeper: { + success: true, + result: { + roles: { + realmAccess: [ + 'DELETE_PRACTITIONERROLE', + 'POST_CARETEAM', + 'GET_PATIENT', + 'MANAGE_PRACTITIONER', + 'DELETE_ORGANIZATIONAFFILIATION', + 'DELETE_QUESTIONNAIRE', + 'MANAGE_QUESTIONNAIRERESPONSE', + 'PUT_OBSERVATION', + 'DELETE_PATIENT', + 'PUT_CARETEAM', + 'MANAGE_PLANDEFINITION', + 'POST_QUESTIONNAIRE', + 'GET_PLANDEFINITION', + 'MANAGE_PATIENT', + 'MANAGE_QUESTIONNAIRE', + 'POST_QUESTIONNAIRERESPONSE', + 'MANAGE_LOCATION', + 'GET_OBSERVATION', + 'POST_LOCATION', + 'POST_PLANDEFINITION', + 'MANAGE_ORGANIZATIONAFFILIATION', + 'GET_PRACTITIONERROLE', + 'PUT_ORGANIZATIONAFFILIATION', + 'MANAGE_CARETEAM', + 'PUT_PLANDEFINITION', + 'POST_ORGANIZATIONAFFILIATION', + 'GET_QUESTIONNAIRERESPONSE', + 'DELETE_PRACTITIONER', + 'PUT_ORGANIZATION', + 'MANAGE_OBSERVATION', + 'DELETE_PLANDEFINITION', + 'GET_LOCATION', + 'DELETE_QUESTIONNAIRERESPONSE', + 'POST_GROUP', + 'POST_PRACTITIONERROLE', + 'POST_PATIENT', + 'DELETE_LOCATION', + 'PUT_PRACTITIONERROLE', + 'DELETE_HEALTHCARESERVICE', + 'POST_OBSERVATION', + 'PUT_GROUP', + 'DELETE_CARETEAM', + 'GET_PRACTITIONER', + 'PUT_PRACTITIONER', + 'POST_PRACTITIONER', + 'GET_ORGANIZATIONAFFILIATION', + 'DELETE_ORGANIZATION', + 'PUT_QUESTIONNAIRE', + 'PUT_HEALTHCARESERVICE', + 'PUT_LOCATION', + 'GET_QUESTIONNAIRE', + 'GET_HEALTHCARESERVICE', + 'DELETE_OBSERVATION', + 'DELETE_GROUP', + 'MANAGE_PRACTITIONERROLE', + 'MANAGE_WebDataImport', + 'PUT_PATIENT', + 'MANAGE_GROUP', + 'POST_HEALTHCARESERVICE', + 'MANAGE_HEALTHCARESERVICE', + 'GET_GROUP', + 'MANAGE_ORGANIZATION', + 'POST_ORGANIZATION', + 'GET_CARETEAM', + 'PUT_QUESTIONNAIRERESPONSE', + 'GET_ORGANIZATION', + ], + clientRoles: { + 'realm-management': [ + 'view-identity-providers', + 'view-realm', + 'manage-identity-providers', + 'impersonation', + 'realm-admin', + 'create-client', + 'manage-users', + 'query-realms', + 'view-authorization', + 'query-clients', + 'query-users', + 'manage-events', + 'manage-realm', + 'view-events', + 'view-users', + 'view-clients', + 'manage-authorization', + 'manage-clients', + 'query-groups', + ], + }, + }, + email: null, + username: 'demo', + user_id: '14f87819-d6fb-4204-973f-629f66581003', + preferred_name: 'demo stration', + family_name: 'stration', + given_name: 'demo', + email_verified: false, + oAuth2Data: { + access_token: + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJBLVBTdnVlNk1uOW1xUDFfYWd3Z3ZJeEM1d21lWUllcktqcE9fRTRKb0ZrIn0.eyJleHAiOjE3MjM4MTEwMzgsImlhdCI6MTcyMzc5MzAzOCwiYXV0aF90aW1lIjoxNzIzNzkzMDM4LCJqdGkiOiIxZTE4NzYyMi05NDgxLTRhYmYtYWRhYi1hZjBiZGZiMjVkMWIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsImF1ZCI6InJlYWxtLW1hbmFnZW1lbnQiLCJzdWIiOiIxNGY4NzgxOS1kNmZiLTQyMDQtOTczZi02MjlmNjY1ODEwMDMiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHBzOi8vZmhpci13ZWIub3BlbnNycC1zdGFnZS5zbWFydHJlZ2lzdGVyLm9yZyIsImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIsImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiREVMRVRFX1BSQUNUSVRJT05FUlJPTEUiLCJQT1NUX0NBUkVURUFNIiwiR0VUX1BBVElFTlQiLCJNQU5BR0VfUFJBQ1RJVElPTkVSIiwiREVMRVRFX09SR0FOSVpBVElPTkFGRklMSUFUSU9OIiwiREVMRVRFX1FVRVNUSU9OTkFJUkUiLCJNQU5BR0VfUVVFU1RJT05OQUlSRVJFU1BPTlNFIiwiUFVUX09CU0VSVkFUSU9OIiwiREVMRVRFX1BBVElFTlQiLCJQVVRfQ0FSRVRFQU0iLCJNQU5BR0VfUExBTkRFRklOSVRJT04iLCJQT1NUX1FVRVNUSU9OTkFJUkUiLCJHRVRfUExBTkRFRklOSVRJT04iLCJNQU5BR0VfUEFUSUVOVCIsIk1BTkFHRV9RVUVTVElPTk5BSVJFIiwiUE9TVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJNQU5BR0VfTE9DQVRJT04iLCJHRVRfT0JTRVJWQVRJT04iLCJQT1NUX0xPQ0FUSU9OIiwiUE9TVF9QTEFOREVGSU5JVElPTiIsIk1BTkFHRV9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkdFVF9QUkFDVElUSU9ORVJST0xFIiwiUFVUX09SR0FOSVpBVElPTkFGRklMSUFUSU9OIiwiTUFOQUdFX0NBUkVURUFNIiwiUFVUX1BMQU5ERUZJTklUSU9OIiwiUE9TVF9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkdFVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJERUxFVEVfUFJBQ1RJVElPTkVSIiwiUFVUX09SR0FOSVpBVElPTiIsIk1BTkFHRV9PQlNFUlZBVElPTiIsIkRFTEVURV9QTEFOREVGSU5JVElPTiIsIkdFVF9MT0NBVElPTiIsIkRFTEVURV9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJQT1NUX0dST1VQIiwiUE9TVF9QUkFDVElUSU9ORVJST0xFIiwiUE9TVF9QQVRJRU5UIiwiREVMRVRFX0xPQ0FUSU9OIiwiUFVUX1BSQUNUSVRJT05FUlJPTEUiLCJERUxFVEVfSEVBTFRIQ0FSRVNFUlZJQ0UiLCJQT1NUX09CU0VSVkFUSU9OIiwiUFVUX0dST1VQIiwiREVMRVRFX0NBUkVURUFNIiwiR0VUX1BSQUNUSVRJT05FUiIsIlBVVF9QUkFDVElUSU9ORVIiLCJQT1NUX1BSQUNUSVRJT05FUiIsIkdFVF9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkRFTEVURV9PUkdBTklaQVRJT04iLCJQVVRfUVVFU1RJT05OQUlSRSIsIlBVVF9IRUFMVEhDQVJFU0VSVklDRSIsIlBVVF9MT0NBVElPTiIsIkdFVF9RVUVTVElPTk5BSVJFIiwiR0VUX0hFQUxUSENBUkVTRVJWSUNFIiwiREVMRVRFX09CU0VSVkFUSU9OIiwiREVMRVRFX0dST1VQIiwiTUFOQUdFX1BSQUNUSVRJT05FUlJPTEUiLCJNQU5BR0VfV2ViRGF0YUltcG9ydCIsIlBVVF9QQVRJRU5UIiwiTUFOQUdFX0dST1VQIiwiUE9TVF9IRUFMVEhDQVJFU0VSVklDRSIsIk1BTkFHRV9IRUFMVEhDQVJFU0VSVklDRSIsIkdFVF9HUk9VUCIsIk1BTkFHRV9PUkdBTklaQVRJT04iLCJQT1NUX09SR0FOSVpBVElPTiIsIkdFVF9DQVJFVEVBTSIsIlBVVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJHRVRfT1JHQU5JWkFUSU9OIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsInZpZXctcmVhbG0iLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsInJlYWxtLWFkbWluIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6ImQyM2FmZGE0LTc2ODAtNDA4YS1hYThhLTY1ODQyYjk2ODY4MyIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6ImRlbW8gc3RyYXRpb24iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZW1vIiwiZ2l2ZW5fbmFtZSI6ImRlbW8iLCJmYW1pbHlfbmFtZSI6InN0cmF0aW9uIiwiZW1haWwiOiJkZW1vQG9uYS5pbyJ9.DQMZRWgV9TkaxnsK4ztsE4TcI8kUwDFHxC_df0q-9F8dgG5xKzHGo6K-aUMfaFNsQw4tUrcPCwxoYWaGWGyv8SUn2tTvuc-53vit1G3L3F3fU-JtDGgo4NR1jb6r8dFAxcrzzS7DYPkGV8nU8l7VQvZZYmGq4OQi-WXXoEivGhX8lnrFN-bi-Iwo8gkPiezhW9u15GXbptsNMSUTUAZkRLQzKaFGJlolgi_Kg5tIEAi7TdpOLOZwZofgi7952bWdU1yNwHnEk-n4rUXVZHmbFu-Og5AgWB2nSw26TaaK19nwCZPjGeQdq9KwMpsJhq2kPc27LiiAiy7mrPmUsTSK-w', + expires_in: 18000, + refresh_expires_in: 1800, + refresh_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4ODhhODNiNC0yODRlLTQxZjAtYmZlOC1kNmEzNWE4ZmJiNmQifQ.eyJleHAiOjE3MjM3OTQ4MzgsImlhdCI6MTcyMzc5MzAzOCwianRpIjoiNGM2ZWEzMmUtNmY0Mi00NDI5LWE2YTUtYzMyMGI5N2UzMzkxIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL2ZoaXIiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsInN1YiI6IjE0Zjg3ODE5LWQ2ZmItNDIwNC05NzNmLTYyOWY2NjU4MTAwMyIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwic2lkIjoiZDIzYWZkYTQtNzY4MC00MDhhLWFhOGEtNjU4NDJiOTY4NjgzIn0.KUvHib106XyypWK30-yw3CGKl9uITts74vsuErgFRP8', + token_type: 'Bearer', + id_token: + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJBLVBTdnVlNk1uOW1xUDFfYWd3Z3ZJeEM1d21lWUllcktqcE9fRTRKb0ZrIn0.eyJleHAiOjE3MjM4MTEwMzgsImlhdCI6MTcyMzc5MzAzOCwiYXV0aF90aW1lIjoxNzIzNzkzMDM4LCJqdGkiOiJhODE0MmRmNi03ODYwLTQyY2ItOGNmMi05MDhmMzIyMzRlODUiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsImF1ZCI6ImZoaXItd2ViIiwic3ViIjoiMTRmODc4MTktZDZmYi00MjA0LTk3M2YtNjI5ZjY2NTgxMDAzIiwidHlwIjoiSUQiLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJhdF9oYXNoIjoic3kzWG9iVWR5VWdKM19oVGVKR1dUQSIsImFjciI6IjEiLCJzaWQiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJkZW1vIHN0cmF0aW9uIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZGVtbyIsImdpdmVuX25hbWUiOiJkZW1vIiwiZmFtaWx5X25hbWUiOiJzdHJhdGlvbiIsImVtYWlsIjoiZGVtb0BvbmEuaW8ifQ.IsuK1aT_qPK8C_LvJfSh4wMu-msYyXxyXirVvvYpV8jnDnRJ3YvxbTDTuNuCcZoPZDrU_mj2Nz3z3OHScrnzu3UGkclFnsK_98qOAoe48Iaa-opa_WImMW1HaNKJFqaUXm4m9a09EFIf7gw0Xx7RjNLr205p231s5jKYOD_Rla1waPacpFq4pR2Zii-8L8qtEnURiHoENnCSRrTIb59Z_O3cnixT5EurRAnhT4k-Yu-pSg037JHZc0CBZvypXf-PQ1hORmQBGIoQuEqJ8JEFrEtsUvdTqAsW-wWeB3OfnMQfyLdCihVD1H6T-ZgbrI3cSHO2Zh_Xw48Z-TXoUJilGw', + 'not-before-policy': 0, + session_state: 'd23afda4-7680-408a-aa8a-65842b968683', + scope: 'openid profile email', + token_expires_at: '2024-08-16T12:23:58.000Z', + refresh_expires_at: '2024-08-16T12:53:58.000Z', + }, + }, + }, + session: { + authenticated: true, + extraData: { + roles: { + realmAccess: [ + 'DELETE_PRACTITIONERROLE', + 'POST_CARETEAM', + 'GET_PATIENT', + 'MANAGE_PRACTITIONER', + 'DELETE_ORGANIZATIONAFFILIATION', + 'DELETE_QUESTIONNAIRE', + 'MANAGE_QUESTIONNAIRERESPONSE', + 'PUT_OBSERVATION', + 'DELETE_PATIENT', + 'PUT_CARETEAM', + 'MANAGE_PLANDEFINITION', + 'POST_QUESTIONNAIRE', + 'GET_PLANDEFINITION', + 'MANAGE_PATIENT', + 'MANAGE_QUESTIONNAIRE', + 'POST_QUESTIONNAIRERESPONSE', + 'MANAGE_LOCATION', + 'GET_OBSERVATION', + 'POST_LOCATION', + 'POST_PLANDEFINITION', + 'MANAGE_ORGANIZATIONAFFILIATION', + 'GET_PRACTITIONERROLE', + 'PUT_ORGANIZATIONAFFILIATION', + 'MANAGE_CARETEAM', + 'PUT_PLANDEFINITION', + 'POST_ORGANIZATIONAFFILIATION', + 'GET_QUESTIONNAIRERESPONSE', + 'DELETE_PRACTITIONER', + 'PUT_ORGANIZATION', + 'MANAGE_OBSERVATION', + 'DELETE_PLANDEFINITION', + 'GET_LOCATION', + 'DELETE_QUESTIONNAIRERESPONSE', + 'POST_GROUP', + 'POST_PRACTITIONERROLE', + 'POST_PATIENT', + 'DELETE_LOCATION', + 'PUT_PRACTITIONERROLE', + 'DELETE_HEALTHCARESERVICE', + 'POST_OBSERVATION', + 'PUT_GROUP', + 'DELETE_CARETEAM', + 'GET_PRACTITIONER', + 'PUT_PRACTITIONER', + 'POST_PRACTITIONER', + 'GET_ORGANIZATIONAFFILIATION', + 'DELETE_ORGANIZATION', + 'PUT_QUESTIONNAIRE', + 'PUT_HEALTHCARESERVICE', + 'PUT_LOCATION', + 'GET_QUESTIONNAIRE', + 'GET_HEALTHCARESERVICE', + 'DELETE_OBSERVATION', + 'DELETE_GROUP', + 'MANAGE_PRACTITIONERROLE', + 'MANAGE_WebDataImport', + 'PUT_PATIENT', + 'MANAGE_GROUP', + 'POST_HEALTHCARESERVICE', + 'MANAGE_HEALTHCARESERVICE', + 'GET_GROUP', + 'MANAGE_ORGANIZATION', + 'POST_ORGANIZATION', + 'GET_CARETEAM', + 'PUT_QUESTIONNAIRERESPONSE', + 'GET_ORGANIZATION', + ], + clientRoles: { + 'realm-management': [ + 'view-identity-providers', + 'view-realm', + 'manage-identity-providers', + 'impersonation', + 'realm-admin', + 'create-client', + 'manage-users', + 'query-realms', + 'view-authorization', + 'query-clients', + 'query-users', + 'manage-events', + 'manage-realm', + 'view-events', + 'view-users', + 'view-clients', + 'manage-authorization', + 'manage-clients', + 'query-groups', + ], + }, + }, + email: null, + username: 'demo', + user_id: '14f87819-d6fb-4204-973f-629f66581003', + preferred_name: 'demo stration', + family_name: 'stration', + given_name: 'demo', + email_verified: false, + oAuth2Data: { + access_token: + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJBLVBTdnVlNk1uOW1xUDFfYWd3Z3ZJeEM1d21lWUllcktqcE9fRTRKb0ZrIn0.eyJleHAiOjE3MjM4MTEwMzgsImlhdCI6MTcyMzc5MzAzOCwiYXV0aF90aW1lIjoxNzIzNzkzMDM4LCJqdGkiOiIxZTE4NzYyMi05NDgxLTRhYmYtYWRhYi1hZjBiZGZiMjVkMWIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsImF1ZCI6InJlYWxtLW1hbmFnZW1lbnQiLCJzdWIiOiIxNGY4NzgxOS1kNmZiLTQyMDQtOTczZi02MjlmNjY1ODEwMDMiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHBzOi8vZmhpci13ZWIub3BlbnNycC1zdGFnZS5zbWFydHJlZ2lzdGVyLm9yZyIsImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIsImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiREVMRVRFX1BSQUNUSVRJT05FUlJPTEUiLCJQT1NUX0NBUkVURUFNIiwiR0VUX1BBVElFTlQiLCJNQU5BR0VfUFJBQ1RJVElPTkVSIiwiREVMRVRFX09SR0FOSVpBVElPTkFGRklMSUFUSU9OIiwiREVMRVRFX1FVRVNUSU9OTkFJUkUiLCJNQU5BR0VfUVVFU1RJT05OQUlSRVJFU1BPTlNFIiwiUFVUX09CU0VSVkFUSU9OIiwiREVMRVRFX1BBVElFTlQiLCJQVVRfQ0FSRVRFQU0iLCJNQU5BR0VfUExBTkRFRklOSVRJT04iLCJQT1NUX1FVRVNUSU9OTkFJUkUiLCJHRVRfUExBTkRFRklOSVRJT04iLCJNQU5BR0VfUEFUSUVOVCIsIk1BTkFHRV9RVUVTVElPTk5BSVJFIiwiUE9TVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJNQU5BR0VfTE9DQVRJT04iLCJHRVRfT0JTRVJWQVRJT04iLCJQT1NUX0xPQ0FUSU9OIiwiUE9TVF9QTEFOREVGSU5JVElPTiIsIk1BTkFHRV9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkdFVF9QUkFDVElUSU9ORVJST0xFIiwiUFVUX09SR0FOSVpBVElPTkFGRklMSUFUSU9OIiwiTUFOQUdFX0NBUkVURUFNIiwiUFVUX1BMQU5ERUZJTklUSU9OIiwiUE9TVF9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkdFVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJERUxFVEVfUFJBQ1RJVElPTkVSIiwiUFVUX09SR0FOSVpBVElPTiIsIk1BTkFHRV9PQlNFUlZBVElPTiIsIkRFTEVURV9QTEFOREVGSU5JVElPTiIsIkdFVF9MT0NBVElPTiIsIkRFTEVURV9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJQT1NUX0dST1VQIiwiUE9TVF9QUkFDVElUSU9ORVJST0xFIiwiUE9TVF9QQVRJRU5UIiwiREVMRVRFX0xPQ0FUSU9OIiwiUFVUX1BSQUNUSVRJT05FUlJPTEUiLCJERUxFVEVfSEVBTFRIQ0FSRVNFUlZJQ0UiLCJQT1NUX09CU0VSVkFUSU9OIiwiUFVUX0dST1VQIiwiREVMRVRFX0NBUkVURUFNIiwiR0VUX1BSQUNUSVRJT05FUiIsIlBVVF9QUkFDVElUSU9ORVIiLCJQT1NUX1BSQUNUSVRJT05FUiIsIkdFVF9PUkdBTklaQVRJT05BRkZJTElBVElPTiIsIkRFTEVURV9PUkdBTklaQVRJT04iLCJQVVRfUVVFU1RJT05OQUlSRSIsIlBVVF9IRUFMVEhDQVJFU0VSVklDRSIsIlBVVF9MT0NBVElPTiIsIkdFVF9RVUVTVElPTk5BSVJFIiwiR0VUX0hFQUxUSENBUkVTRVJWSUNFIiwiREVMRVRFX09CU0VSVkFUSU9OIiwiREVMRVRFX0dST1VQIiwiTUFOQUdFX1BSQUNUSVRJT05FUlJPTEUiLCJNQU5BR0VfV2ViRGF0YUltcG9ydCIsIlBVVF9QQVRJRU5UIiwiTUFOQUdFX0dST1VQIiwiUE9TVF9IRUFMVEhDQVJFU0VSVklDRSIsIk1BTkFHRV9IRUFMVEhDQVJFU0VSVklDRSIsIkdFVF9HUk9VUCIsIk1BTkFHRV9PUkdBTklaQVRJT04iLCJQT1NUX09SR0FOSVpBVElPTiIsIkdFVF9DQVJFVEVBTSIsIlBVVF9RVUVTVElPTk5BSVJFUkVTUE9OU0UiLCJHRVRfT1JHQU5JWkFUSU9OIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsInZpZXctcmVhbG0iLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsInJlYWxtLWFkbWluIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6ImQyM2FmZGE0LTc2ODAtNDA4YS1hYThhLTY1ODQyYjk2ODY4MyIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6ImRlbW8gc3RyYXRpb24iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZW1vIiwiZ2l2ZW5fbmFtZSI6ImRlbW8iLCJmYW1pbHlfbmFtZSI6InN0cmF0aW9uIiwiZW1haWwiOiJkZW1vQG9uYS5pbyJ9.DQMZRWgV9TkaxnsK4ztsE4TcI8kUwDFHxC_df0q-9F8dgG5xKzHGo6K-aUMfaFNsQw4tUrcPCwxoYWaGWGyv8SUn2tTvuc-53vit1G3L3F3fU-JtDGgo4NR1jb6r8dFAxcrzzS7DYPkGV8nU8l7VQvZZYmGq4OQi-WXXoEivGhX8lnrFN-bi-Iwo8gkPiezhW9u15GXbptsNMSUTUAZkRLQzKaFGJlolgi_Kg5tIEAi7TdpOLOZwZofgi7952bWdU1yNwHnEk-n4rUXVZHmbFu-Og5AgWB2nSw26TaaK19nwCZPjGeQdq9KwMpsJhq2kPc27LiiAiy7mrPmUsTSK-w', + expires_in: 18000, + refresh_expires_in: 1800, + refresh_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4ODhhODNiNC0yODRlLTQxZjAtYmZlOC1kNmEzNWE4ZmJiNmQifQ.eyJleHAiOjE3MjM3OTQ4MzgsImlhdCI6MTcyMzc5MzAzOCwianRpIjoiNGM2ZWEzMmUtNmY0Mi00NDI5LWE2YTUtYzMyMGI5N2UzMzkxIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL2ZoaXIiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsInN1YiI6IjE0Zjg3ODE5LWQ2ZmItNDIwNC05NzNmLTYyOWY2NjU4MTAwMyIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwic2lkIjoiZDIzYWZkYTQtNzY4MC00MDhhLWFhOGEtNjU4NDJiOTY4NjgzIn0.KUvHib106XyypWK30-yw3CGKl9uITts74vsuErgFRP8', + token_type: 'Bearer', + id_token: + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJBLVBTdnVlNk1uOW1xUDFfYWd3Z3ZJeEM1d21lWUllcktqcE9fRTRKb0ZrIn0.eyJleHAiOjE3MjM4MTEwMzgsImlhdCI6MTcyMzc5MzAzOCwiYXV0aF90aW1lIjoxNzIzNzkzMDM4LCJqdGkiOiJhODE0MmRmNi03ODYwLTQyY2ItOGNmMi05MDhmMzIyMzRlODUiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZmhpciIsImF1ZCI6ImZoaXItd2ViIiwic3ViIjoiMTRmODc4MTktZDZmYi00MjA0LTk3M2YtNjI5ZjY2NTgxMDAzIiwidHlwIjoiSUQiLCJhenAiOiJmaGlyLXdlYiIsInNlc3Npb25fc3RhdGUiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJhdF9oYXNoIjoic3kzWG9iVWR5VWdKM19oVGVKR1dUQSIsImFjciI6IjEiLCJzaWQiOiJkMjNhZmRhNC03NjgwLTQwOGEtYWE4YS02NTg0MmI5Njg2ODMiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJkZW1vIHN0cmF0aW9uIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZGVtbyIsImdpdmVuX25hbWUiOiJkZW1vIiwiZmFtaWx5X25hbWUiOiJzdHJhdGlvbiIsImVtYWlsIjoiZGVtb0BvbmEuaW8ifQ.IsuK1aT_qPK8C_LvJfSh4wMu-msYyXxyXirVvvYpV8jnDnRJ3YvxbTDTuNuCcZoPZDrU_mj2Nz3z3OHScrnzu3UGkclFnsK_98qOAoe48Iaa-opa_WImMW1HaNKJFqaUXm4m9a09EFIf7gw0Xx7RjNLr205p231s5jKYOD_Rla1waPacpFq4pR2Zii-8L8qtEnURiHoENnCSRrTIb59Z_O3cnixT5EurRAnhT4k-Yu-pSg037JHZc0CBZvypXf-PQ1hORmQBGIoQuEqJ8JEFrEtsUvdTqAsW-wWeB3OfnMQfyLdCihVD1H6T-ZgbrI3cSHO2Zh_Xw48Z-TXoUJilGw', + 'not-before-policy': 0, + session_state: 'd23afda4-7680-408a-aa8a-65842b968683', + scope: 'openid profile email', + token_expires_at: '2024-08-16T12:23:58.000Z', + refresh_expires_at: '2024-08-16T12:53:58.000Z', + }, + }, + user: { email: '', gravatar: '', name: '', username: 'demo' }, + }, + session_expires_at: '2024-08-16T10:23:58.602Z', + }, +}; diff --git a/src/app/dollar-imports/tests/importerConfigWriter.test.ts b/src/app/dollar-imports/tests/importerConfigWriter.test.ts deleted file mode 100644 index be23a20..0000000 --- a/src/app/dollar-imports/tests/importerConfigWriter.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { generateImporterSCriptConfig } from '../importerConfigWriter'; - -jest.mock('../../../configs/envs'); - -afterEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); -}); - -test('generates importer config content correctly ', () => { - const response = generateImporterSCriptConfig('at', 'rt'); - expect(response).toMatchInlineSnapshot(` - "access_token = \\"at\\" - refresh_token = \\"rt\\" - keycloak_url = \\"http://reveal-stage.smartregister.org/auth\\" - " - `); -}); diff --git a/src/app/dollar-imports/tests/job.test.ts b/src/app/dollar-imports/tests/job.test.ts index f1f0d76..8d166a7 100644 --- a/src/app/dollar-imports/tests/job.test.ts +++ b/src/app/dollar-imports/tests/job.test.ts @@ -1,27 +1,73 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import path from 'path'; -import { getImportScriptArgs } from '../job'; -import { UploadWorkflowTypes } from '../utils'; +import { getImportScriptArgs } from '../helpers/job'; +import { UploadWorkflowTypes } from '../helpers/utils'; -const sampleCsv = path.resolve(__dirname, 'fixtures/sample.csv'); +const sampleCsv = `${path.resolve(__dirname, 'fixtures/sample.csv')}`; test('generates correct script args for the different workflows', () => { const common = ['--log_level', 'info']; - let result = getImportScriptArgs(UploadWorkflowTypes.Locations, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--resource_type', 'locations', ...common]); - result = getImportScriptArgs(UploadWorkflowTypes.Users, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--resource_type', 'users', ...common]); - result = getImportScriptArgs(UploadWorkflowTypes.CareTeams, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--resource_type', 'careTeams', '--log_level', 'info']); - result = getImportScriptArgs(UploadWorkflowTypes.orgToLocationAssignment, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--assign', 'organizations-Locations', '--log_level', 'info']); - result = getImportScriptArgs(UploadWorkflowTypes.userToOrganizationAssignment, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--assign', 'users-organizations', '--log_level', 'info']); - result = getImportScriptArgs(UploadWorkflowTypes.Organizations, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--resource_type', 'organizations', '--log_level', 'info']); - result = getImportScriptArgs(UploadWorkflowTypes.Products, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--setup', 'products', '--log_level', 'info']); - result = getImportScriptArgs(UploadWorkflowTypes.Inventories, sampleCsv); - expect(result).toEqual(['--csv_file', sampleCsv, '--setup', 'inventories', '--log_level', 'info']); + const commonWorkflowArgs = { + workflowType: UploadWorkflowTypes.Locations, + filePath: sampleCsv, + productListId: 'productId', + inventoryListId: 'inventoryId', + workflowId: 'id', + author: 'JK Rowling', + accessToken: 'at', + refreshToken: 'rt', + }; + let result = getImportScriptArgs(commonWorkflowArgs); + expect(result).toEqual([ + '--csv_file', + `"${sampleCsv}"`, + '--resource_type', + 'locations', + '--location_type_coding_system', + 'http://smartregister.org/CodeSystem/eusm-service-point-type', + ...common, + ]); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.Users }); + expect(result).toEqual(['--csv_file', `"${sampleCsv}"`, '--resource_type', 'users', ...common]); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.CareTeams }); + expect(result).toEqual(['--csv_file', `"${sampleCsv}"`, '--resource_type', 'careTeams', '--log_level', 'info']); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.orgToLocationAssignment }); + expect(result).toEqual([ + '--csv_file', + `"${sampleCsv}"`, + '--assign', + 'organizations-Locations', + '--log_level', + 'info', + ]); + result = getImportScriptArgs({ + ...commonWorkflowArgs, + workflowType: UploadWorkflowTypes.userToOrganizationAssignment, + }); + expect(result).toEqual(['--csv_file', `"${sampleCsv}"`, '--assign', 'users-organizations', '--log_level', 'info']); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.Organizations }); + expect(result).toEqual(['--csv_file', `"${sampleCsv}"`, '--resource_type', 'organizations', '--log_level', 'info']); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.Products }); + expect(result).toEqual([ + '--csv_file', + `"${sampleCsv}"`, + '--setup', + 'products', + '--list_resource_id', + 'productId', + '--log_level', + 'info', + ]); + result = getImportScriptArgs({ ...commonWorkflowArgs, workflowType: UploadWorkflowTypes.Inventories }); + expect(result).toEqual([ + '--csv_file', + `"${sampleCsv}"`, + '--setup', + 'inventories', + '--list_resource_id', + 'inventoryId', + '--log_level', + 'info', + ]); }); diff --git a/src/app/helpers/utils.ts b/src/app/helpers/utils.ts index 936cd0e..39a60d7 100644 --- a/src/app/helpers/utils.ts +++ b/src/app/helpers/utils.ts @@ -1,3 +1,5 @@ +import { EXPRESS_OPENSRP_ACCESS_TOKEN_URL } from '../../configs/envs'; + export function parseKeycloakUrl(keycloakUrl: string) { // Parse the URL const parsedUrl = new URL(keycloakUrl); @@ -18,3 +20,5 @@ export function parseKeycloakUrl(keycloakUrl: string) { } return { realm, keycloakBaseUrl }; } + +export const { realm, keycloakBaseUrl } = parseKeycloakUrl(EXPRESS_OPENSRP_ACCESS_TOKEN_URL); diff --git a/src/app/index.ts b/src/app/index.ts index 9f6404b..99b3112 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -99,8 +99,8 @@ const sess = { secure: false, }, name: sessionName, - resave: true, - saveUninitialized: true, + resave: false, + saveUninitialized: false, secret: EXPRESS_SESSION_SECRET || 'hunter2', store: sessionStore, }; diff --git a/src/app/tests/index.test.ts b/src/app/tests/index.test.ts index ed3c917..570d206 100644 --- a/src/app/tests/index.test.ts +++ b/src/app/tests/index.test.ts @@ -3,7 +3,6 @@ import MockDate from 'mockdate'; import ClientOauth2 from 'client-oauth2'; import request from 'supertest'; import express from 'express'; -import Redis from 'ioredis'; import { resolve } from 'path'; import { EXPRESS_FRONTEND_OPENSRP_CALLBACK_URL, @@ -125,13 +124,9 @@ describe('src/index.ts', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let cookie: { [key: string]: any }; - afterEach((done) => { + afterEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); - new Redis() - .flushall() - .then(() => done()) - .catch((err) => panic(err, done)); }); it('serves the build.index.html file', (done) => { @@ -248,22 +243,24 @@ describe('src/index.ts', () => { }); }); - it('/refresh/token works correctly when session life time is exceeded', (done) => { - MockDate.set('1/2/2020'); - request(app) - .get('/refresh/token') - .set('cookie', sessionString) - .then((res: request.Response) => { - expect(res.status).toEqual(500); - expect(res.body).toEqual({ - message: 'Session is Expired', - }); - done(); - }) - .catch((err: Error) => { - panic(err, done); - }); - }); + // TODO - tests cases depend on each other. This test case depends on a test case above it to set the session string. + // it('/refresh/token works correctly when session life time is exceeded', (done) => { + // MockDate.set('1/2/2020'); + // console.log({sessionString}) + // request(app) + // .get('/refresh/token') + // .set('cookie', sessionString) + // .then((res: request.Response) => { + // expect(res.status).toEqual(500); + // expect(res.body).toEqual({ + // message: 'Session is Expired', + // }); + // done(); + // }) + // .catch((err: Error) => { + // panic(err, done); + // }); + // }); it('/refresh/token does not change session expiry date', (done) => { // change date @@ -499,6 +496,7 @@ describe('src/index.ts', () => { jest.resetModules(); jest.mock('../../configs/envs', () => ({ ...jest.requireActual('../../configs/envs'), + EXPRESS_REDIS_STAND_ALONE_URL: undefined, EXPRESS_REDIS_SENTINEL_CONFIG: '{"name":"mymaster","sentinels":[{"host":"127.0.0.1","port":26379},{"host":"127.0.0.1","port":6380},{"host":"127.0.0.1","port":6379}]}', })); @@ -509,7 +507,8 @@ describe('src/index.ts', () => { request(app2) .get('/test/endpoint') .then(() => { - expect(logsSpy).toHaveBeenCalledWith('Redis sentinel client connected!'); + expect(logsSpy.mock.calls[0]).toEqual(['Redis sentinel client connected!']); + done(); }) .catch((err) => { diff --git a/src/configs/envs.ts b/src/configs/envs.ts index 7ed90a8..b45e64c 100644 --- a/src/configs/envs.ts +++ b/src/configs/envs.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-redeclare */ /* eslint-disable @typescript-eslint/naming-convention */ +import { randomUUID } from 'crypto'; import dotenv from 'dotenv'; import path from 'path'; @@ -84,3 +85,7 @@ export const EXPRESS_OPENSRP_SCOPES = (process.env.EXPRESS_OPENSRP_SCOPES || 'op export const { EXPRESS_OPENSRP_SERVER_URL } = process.env; export const EXPRESS_TEMP_CSV_FILE_STORAGE = process.env.EXPRESS_TEMP_CSV_FILE_STORAGE || '/tmp/csvUploads'; + +export const EXPRESS_PYTHON_INTERPRETER_PATH = process.env.EXPRESS_PYTHON_INTERPRETER_PATH || 'python'; + +export const EXPRESS_BULK_UPLOAD_REDIS_QUEUE = process.env.EXPRESS_BULK_UPLOAD_REDIS_QUEUE || randomUUID(); diff --git a/src/constants.ts b/src/constants.ts index b27d692..44378ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,3 +3,4 @@ export const SESSION_IS_EXPIRED = 'Session is Expired'; export const TOKEN_NOT_FOUND = 'Access token or Refresh token not found'; export const TOKEN_REFRESH_FAILED = 'Failed to refresh token'; export const A_DAY = 1 * 24 * 60 * 60; +export const BULK_UPLOAD_CONCURRENT_JOBS = 5; diff --git a/yarn.lock b/yarn.lock index 4b501f6..26d8e5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,6 +25,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/code-frame@npm:7.25.9" + dependencies: + "@babel/highlight": "npm:^7.25.9" + picocolors: "npm:^1.0.0" + checksum: 10c0/88562eba0eeb5960b7004e108790aa00183d90cbbe70ce10dad01c2c48141d2ef54d6dcd0c678cc1e456de770ffeb68e28559f4d222c01a110c79aea8733074b + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.24.8": version: 7.24.9 resolution: "@babel/compat-data@npm:7.24.9" @@ -32,7 +42,37 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.1.0, @babel/core@npm:^7.12.3, @babel/core@npm:^7.7.2, @babel/core@npm:^7.8.0": +"@babel/compat-data@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/compat-data@npm:7.25.9" + checksum: 10c0/8d9fc2074311ce61aaf5bccf740a808644d19d4859caf5fa46d8a7186a1ee0b0d8cbbc23f9371f8b397e84a885bdeab58d5f22d6799ddde55973252aac351a27 + languageName: node + linkType: hard + +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.23.9": + version: 7.25.9 + resolution: "@babel/core@npm:7.25.9" + dependencies: + "@ampproject/remapping": "npm:^2.2.0" + "@babel/code-frame": "npm:^7.25.9" + "@babel/generator": "npm:^7.25.9" + "@babel/helper-compilation-targets": "npm:^7.25.9" + "@babel/helper-module-transforms": "npm:^7.25.9" + "@babel/helpers": "npm:^7.25.9" + "@babel/parser": "npm:^7.25.9" + "@babel/template": "npm:^7.25.9" + "@babel/traverse": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/40d3064ebe906f65ed4153a0f4d75c679a19e4d71e425035b7bbe2d292a9167274f1a0d908d4d6c8f484fcddeb10bd91e0c7878fdb3dfad1bb00f6a319ce431d + languageName: node + linkType: hard + +"@babel/core@npm:^7.12.3": version: 7.24.9 resolution: "@babel/core@npm:7.24.9" dependencies: @@ -67,6 +107,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/generator@npm:7.25.9" + dependencies: + "@babel/types": "npm:^7.25.9" + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + jsesc: "npm:^3.0.2" + checksum: 10c0/fca49a1440ac550bb835a73c0e8314849cd493a468a5431ca7f9dbb3d3443e3a1a6dcba2426752e8a97cc2feed4a3b7a0c639e1c45871c4a9dd0c994f08dd25a + languageName: node + linkType: hard + "@babel/helper-compilation-targets@npm:^7.24.8": version: 7.24.8 resolution: "@babel/helper-compilation-targets@npm:7.24.8" @@ -80,6 +132,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-compilation-targets@npm:7.25.9" + dependencies: + "@babel/compat-data": "npm:^7.25.9" + "@babel/helper-validator-option": "npm:^7.25.9" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/a6b26a1e4222e69ef8e62ee19374308f060b007828bc11c65025ecc9e814aba21ff2175d6d3f8bf53c863edd728ee8f94ba7870f8f90a37d39552ad9933a8aaa + languageName: node + linkType: hard + "@babel/helper-environment-visitor@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-environment-visitor@npm:7.24.7" @@ -118,6 +183,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-module-imports@npm:7.25.9" + dependencies: + "@babel/traverse": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + checksum: 10c0/078d3c2b45d1f97ffe6bb47f61961be4785d2342a4156d8b42c92ee4e1b7b9e365655dd6cb25329e8fe1a675c91eeac7e3d04f0c518b67e417e29d6e27b6aa70 + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.24.9": version: 7.24.9 resolution: "@babel/helper-module-transforms@npm:7.24.9" @@ -133,6 +208,20 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-module-transforms@npm:7.25.9" + dependencies: + "@babel/helper-module-imports": "npm:^7.25.9" + "@babel/helper-simple-access": "npm:^7.25.9" + "@babel/helper-validator-identifier": "npm:^7.25.9" + "@babel/traverse": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/cd005e7585806845d79c5c0ca9e8926f186b430b0a558dad08a3611365eaad3ac587672b0d903530117dec454f48b6bdc3d164b19ea1b71ca1b4eb3be7b452ef + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.24.7, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.24.8 resolution: "@babel/helper-plugin-utils@npm:7.24.8" @@ -140,6 +229,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-plugin-utils@npm:7.25.9" + checksum: 10c0/483066a1ba36ff16c0116cd24f93de05de746a603a777cd695ac7a1b034928a65a4ecb35f255761ca56626435d7abdb73219eba196f9aa83b6c3c3169325599d + languageName: node + linkType: hard + "@babel/helper-simple-access@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-simple-access@npm:7.24.7" @@ -150,6 +246,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-simple-access@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-simple-access@npm:7.25.9" + dependencies: + "@babel/traverse": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + checksum: 10c0/3f1bcdb88ee3883ccf86959869a867f6bbf8c4737cd44fb9f799c38e54f67474590bc66802500ae9fe18161792875b2cfb7ec15673f48ed6c8663f6d09686ca8 + languageName: node + linkType: hard + "@babel/helper-split-export-declaration@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-split-export-declaration@npm:7.24.7" @@ -194,6 +300,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-validator-option@npm:7.25.9" + checksum: 10c0/27fb195d14c7dcb07f14e58fe77c44eea19a6a40a74472ec05c441478fa0bb49fa1c32b2d64be7a38870ee48ef6601bdebe98d512f0253aea0b39756c4014f3e + languageName: node + linkType: hard + "@babel/helpers@npm:^7.24.8": version: 7.24.8 resolution: "@babel/helpers@npm:7.24.8" @@ -204,6 +317,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helpers@npm:7.25.9" + dependencies: + "@babel/template": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + checksum: 10c0/4354fbf050291937d0f127f6f927a0c471b604524e0767516fefb91dc36427f25904dd0d2b2b3bbc66bce1894c680cc37eac9ab46970d70f24bf3e53375612de + languageName: node + linkType: hard + "@babel/highlight@npm:^7.24.7": version: 7.24.7 resolution: "@babel/highlight@npm:7.24.7" @@ -216,6 +339,18 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/highlight@npm:7.25.9" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.25.9" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/ae0ed93c151b85a07df42936117fa593ce91563a22dfc8944a90ae7088c9679645c33e00dcd20b081c1979665d65f986241172dae1fc9e5922692fc3ff685a49 + languageName: node + linkType: hard + "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.24.8": version: 7.24.8 resolution: "@babel/parser@npm:7.24.8" @@ -225,6 +360,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.9, @babel/parser@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/parser@npm:7.25.9" + dependencies: + "@babel/types": "npm:^7.25.9" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/143faff8a72331be5ed94080e0f4645cbeea814fb488cd9210154083735f67cb66fde32f6a4a80efd6c4cdf12c6f8b50995a465846093c7f65c5da8d7829627c + languageName: node + linkType: hard + "@babel/plugin-syntax-async-generators@npm:^7.8.4": version: 7.8.4 resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" @@ -280,6 +426,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.7.2": + version: 7.25.9 + resolution: "@babel/plugin-syntax-jsx@npm:7.25.9" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/d56597aff4df39d3decda50193b6dfbe596ca53f437ff2934622ce19a743bf7f43492d3fb3308b0289f5cee2b825d99ceb56526a2b9e7b68bf04901546c5618c + languageName: node + linkType: hard + "@babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -388,7 +545,18 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.24.8, @babel/traverse@npm:^7.7.2": +"@babel/template@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/template@npm:7.25.9" + dependencies: + "@babel/code-frame": "npm:^7.25.9" + "@babel/parser": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + checksum: 10c0/ebe677273f96a36c92cc15b7aa7b11cc8bc8a3bb7a01d55b2125baca8f19cae94ff3ce15f1b1880fb8437f3a690d9f89d4e91f16fc1dc4d3eb66226d128983ab + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.24.8": version: 7.24.8 resolution: "@babel/traverse@npm:7.24.8" dependencies: @@ -406,6 +574,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/traverse@npm:7.25.9" + dependencies: + "@babel/code-frame": "npm:^7.25.9" + "@babel/generator": "npm:^7.25.9" + "@babel/parser": "npm:^7.25.9" + "@babel/template": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + debug: "npm:^4.3.1" + globals: "npm:^11.1.0" + checksum: 10c0/e90be586a714da4adb80e6cb6a3c5cfcaa9b28148abdafb065e34cc109676fc3db22cf98cd2b2fff66ffb9b50c0ef882cab0f466b6844be0f6c637b82719bba1 + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.24.9, @babel/types@npm:^7.3.3": version: 7.24.9 resolution: "@babel/types@npm:7.24.9" @@ -417,6 +600,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/types@npm:7.25.9" + dependencies: + "@babel/helper-string-parser": "npm:^7.25.9" + "@babel/helper-validator-identifier": "npm:^7.25.9" + checksum: 10c0/33890d08bcb06b26a3a60e4c6c996cbdf2b8d8a3c212664de659c2775f80b002c5f2bceedaa309c384ff5e99bd579794fe6a7e41de07df70246f43c55016d349 + languageName: node + linkType: hard + "@babel/types@npm:^7.8.3": version: 7.26.3 resolution: "@babel/types@npm:7.26.3" @@ -427,6 +620,13 @@ __metadata: languageName: node linkType: hard +"@balena/dockerignore@npm:^1.0.2": + version: 1.0.2 + resolution: "@balena/dockerignore@npm:1.0.2" + checksum: 10c0/0bcb067e86f6734ab943ce4ce9a7c8611f2e983a70bccebf9d2309db57695c09dded7faf5be49c929c4c9e9a9174ae55fc625626de0fb9958823c37423d12f4e + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -494,6 +694,13 @@ __metadata: languageName: node linkType: hard +"@fastify/busboy@npm:^2.0.0": + version: 2.1.1 + resolution: "@fastify/busboy@npm:2.1.1" + checksum: 10c0/6f8027a8cba7f8f7b736718b013f5a38c0476eea67034c94a0d3c375e2b114366ad4419e6a6fa7ffc2ef9c6d3e0435d76dd584a7a1cbac23962fda7650b579e3 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" @@ -569,57 +776,57 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/schema@npm:^0.1.2": +"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a languageName: node linkType: hard -"@jest/console@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/console@npm:27.5.1" +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" - checksum: 10c0/6cb46d721698aaeb0d57ace967f7a36bbefc20719d420ea8bf8ec8adf9994cb1ec11a93bbd9b1514c12a19b5dd99dcbbd1d3e22fd8bea8e41e845055b03ac18d + checksum: 10c0/7be408781d0a6f657e969cbec13b540c329671819c2f57acfad0dae9dbfe2c9be859f38fe99b35dba9ff1536937dc6ddc69fdcd2794812fa3c647a1619797f6c languageName: node linkType: hard -"@jest/core@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/core@npm:27.5.1" +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/reporters": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" ansi-escapes: "npm:^4.2.1" chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" + ci-info: "npm:^3.2.0" exit: "npm:^0.1.2" graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^27.5.1" - jest-config: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-resolve-dependencies: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - jest-watcher: "npm:^27.5.1" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" micromatch: "npm:^4.0.4" - rimraf: "npm:^3.0.0" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" strip-ansi: "npm:^6.0.0" peerDependencies: @@ -627,153 +834,182 @@ __metadata: peerDependenciesMeta: node-notifier: optional: true - checksum: 10c0/8c858fe99cec9eabde8c894d4313171b923e1d4b8f66884b1fa1b7a0123db9f94b797f77d888a2b57d4832e7e46cd67aa1e2f227f1544643478de021c4b84db2 + checksum: 10c0/934f7bf73190f029ac0f96662c85cd276ec460d407baf6b0dbaec2872e157db4d55a7ee0b1c43b18874602f662b37cb973dda469a4e6d88b4e4845b521adeeb2 languageName: node linkType: hard -"@jest/environment@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/environment@npm:27.5.1" +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" dependencies: - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - checksum: 10c0/50e40b4f0a351a83f21af03c5cffd9f061729aee8f73131dbb32b39838c575a89d313e946ded91c08e16cf58ff470d74d6b3a48f664cec5c70a946aff45310b3 + jest-mock: "npm:^29.7.0" + checksum: 10c0/c7b1b40c618f8baf4d00609022d2afa086d9c6acc706f303a70bb4b67275868f620ad2e1a9efc5edd418906157337cce50589a627a6400bbdf117d351b91ef86 languageName: node linkType: hard -"@jest/fake-timers@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/fake-timers@npm:27.5.1" +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10c0/60b79d23a5358dc50d9510d726443316253ecda3a7fb8072e1526b3e0d3b14f066ee112db95699b7a43ad3f0b61b750c72e28a5a1cac361d7a2bb34747fa938a + languageName: node + linkType: hard + +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - "@sinonjs/fake-timers": "npm:^8.0.1" + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b41f193fb697d3ced134349250aed6ccea075e48c4f803159db102b826a4e473397c68c31118259868fd69a5cba70e97e1c26d2c2ff716ca39dc73a2ccec037e + languageName: node + linkType: hard + +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" "@types/node": "npm:*" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10c0/df6113d11f572219ac61d3946b6cc1aaa8632e3afed9ff959bdb46e122e7cc5b5a16451a88d5fca7cc8daa66333adde3cf70d96c936f3d8406276f6e6e2cbacd + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/cf0a8bcda801b28dc2e2b2ba36302200ee8104a45ad7a21e6c234148932f826cb3bc57c8df3b7b815aeea0861d7b6ca6f0d4778f93b9219398ef28749e03595c languageName: node linkType: hard -"@jest/globals@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/globals@npm:27.5.1" +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - expect: "npm:^27.5.1" - checksum: 10c0/b7309297f13b02bf748782772ab2054bbd11f10eb13e9b4660b33acb8c2c4bc7ee07aa1175045feb27ce3a6916b2d3982a3c5350ea1f9c2c3852334942077471 + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10c0/a385c99396878fe6e4460c43bd7bb0a5cc52befb462cc6e7f2a3810f9e7bcce7cdeb51908fd530391ee452dc856c98baa2c5f5fa8a5b30b071d31ef7f6955cea languageName: node linkType: hard -"@jest/reporters@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/reporters@npm:27.5.1" +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" dependencies: "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" "@types/node": "npm:*" chalk: "npm:^4.0.0" collect-v8-coverage: "npm:^1.0.0" exit: "npm:^0.1.2" - glob: "npm:^7.1.2" + glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^5.1.0" + istanbul-lib-instrument: "npm:^6.0.0" istanbul-lib-report: "npm:^3.0.0" istanbul-lib-source-maps: "npm:^4.0.0" istanbul-reports: "npm:^3.1.3" - jest-haste-map: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" slash: "npm:^3.0.0" - source-map: "npm:^0.6.0" string-length: "npm:^4.0.1" - terminal-link: "npm:^2.0.0" - v8-to-istanbul: "npm:^8.1.0" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10c0/fd66b17ca8af0464759d12525cfd84ae87403132da61f18ee76a2f07ecd64427797f7ad6e56d338ffa9f956cce153444edf1e5775093e9be2903aaf4d0e049bc + checksum: 10c0/a754402a799541c6e5aff2c8160562525e2a47e7d568f01ebfc4da66522de39cbb809bbb0a841c7052e4270d79214e70aec3c169e4eae42a03bc1a8a20cb9fa2 languageName: node linkType: hard -"@jest/source-map@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/source-map@npm:27.5.1" +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" dependencies: + "@sinclair/typebox": "npm:^0.27.8" + checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be + languageName: node + linkType: hard + +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" callsites: "npm:^3.0.0" graceful-fs: "npm:^4.2.9" - source-map: "npm:^0.6.0" - checksum: 10c0/7d9937675ba4cb2f27635b13be0f86588d18cf3b2d5442e818e702ea87afa5048c5f8892c749857fd7dd884fd6e14f799851ec9af61940813a690c6d5a70979e + checksum: 10c0/a2f177081830a2e8ad3f2e29e20b63bd40bade294880b595acf2fc09ec74b6a9dd98f126a2baa2bf4941acd89b13a4ade5351b3885c224107083a0059b60a219 languageName: node linkType: hard -"@jest/test-result@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-result@npm:27.5.1" +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/istanbul-lib-coverage": "npm:^2.0.0" collect-v8-coverage: "npm:^1.0.0" - checksum: 10c0/4fb8cbefda8f645c57e2fc0d0df169b0bf5f6cb456b42dc09f5138595b736e800d8d83e3fd36a47fd801a2359988c841792d7fc46784bec908c88b39b6581749 + checksum: 10c0/7de54090e54a674ca173470b55dc1afdee994f2d70d185c80236003efd3fa2b753fff51ffcdda8e2890244c411fd2267529d42c4a50a8303755041ee493e6a04 languageName: node linkType: hard -"@jest/test-sequencer@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-sequencer@npm:27.5.1" +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" dependencies: - "@jest/test-result": "npm:^27.5.1" + "@jest/test-result": "npm:^29.7.0" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - checksum: 10c0/f43ecfc5b4c736c7f6e8521c13ef7b447ad29f96732675776be69b2631eb76019793a02ad58e69baf7ffbce1cc8d5b62ca30294091c4ad3acbdce6c12b73d049 + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10c0/593a8c4272797bb5628984486080cbf57aed09c7cfdc0a634e8c06c38c6bef329c46c0016e84555ee55d1cd1f381518cf1890990ff845524c1123720c8c1481b languageName: node linkType: hard -"@jest/transform@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/transform@npm:27.5.1" +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.1.0" - "@jest/types": "npm:^27.5.1" + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" babel-plugin-istanbul: "npm:^6.1.1" chalk: "npm:^4.0.0" - convert-source-map: "npm:^1.4.0" - fast-json-stable-stringify: "npm:^2.0.0" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" micromatch: "npm:^4.0.4" pirates: "npm:^4.0.4" slash: "npm:^3.0.0" - source-map: "npm:^0.6.1" - write-file-atomic: "npm:^3.0.0" - checksum: 10c0/2d1819dad9621a562a1ff6eceefeb5ae0900063c50e982b9f08e48d7328a0c343520ba27ce291cb72c113d4f441ef4a95285b9d4ef6604cffd53740e951c99b6 + write-file-atomic: "npm:^4.0.2" + checksum: 10c0/7f4a7f73dcf45dfdf280c7aa283cbac7b6e5a904813c3a93ead7e55873761fc20d5c4f0191d2019004fac6f55f061c82eb3249c2901164ad80e362e7a7ede5a6 languageName: node linkType: hard -"@jest/types@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/types@npm:27.5.1" +"@jest/types@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/types@npm:29.6.3" dependencies: + "@jest/schemas": "npm:^29.6.3" "@types/istanbul-lib-coverage": "npm:^2.0.0" "@types/istanbul-reports": "npm:^3.0.0" "@types/node": "npm:*" - "@types/yargs": "npm:^16.0.0" + "@types/yargs": "npm:^17.0.8" chalk: "npm:^4.0.0" - checksum: 10c0/4598b302398db0eb77168b75a6c58148ea02cc9b9f21c5d1bbe985c1c9257110a5653cf7b901c3cab87fba231e3fed83633687f1c0903b4bc6939ab2a8452504 + checksum: 10c0/ea4e493dd3fb47933b8ccab201ae573dcc451f951dc44ed2a86123cd8541b82aa9d2b1031caf9b1080d6673c517e2dcc25a44b2dc4f3fbc37bfc965d444888c0 languageName: node linkType: hard @@ -809,7 +1045,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -1005,28 +1241,37 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^1.7.0": - version: 1.8.6 - resolution: "@sinonjs/commons@npm:1.8.6" +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e + languageName: node + linkType: hard + +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.1 + resolution: "@sinonjs/commons@npm:3.0.1" dependencies: type-detect: "npm:4.0.8" - checksum: 10c0/93b4d4e27e93652b83467869c2fe09cbd8f37cd5582327f0e081fbf9b93899e2d267db7b668c96810c63dc229867614ced825e5512b47db96ca6f87cb3ec0f61 + checksum: 10c0/1227a7b5bd6c6f9584274db996d7f8cee2c8c350534b9d0141fc662eaf1f292ea0ae3ed19e5e5271c8fd390d27e492ca2803acd31a1978be2cdc6be0da711403 languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^8.0.1": - version: 8.1.0 - resolution: "@sinonjs/fake-timers@npm:8.1.0" +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" dependencies: - "@sinonjs/commons": "npm:^1.7.0" - checksum: 10c0/d6b795f9ddaf044daf184c151555ca557ccd23636f2ee3d2f76a9d128329f81fc1aac412f6f67239ab92cb9390aad9955b71df93cf4bd442c68b1f341e381ab6 + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10c0/2e2fb6cc57f227912814085b7b01fede050cd4746ea8d49a1e44d5a0e56a804663b0340ae2f11af7559ea9bf4d087a11f2f646197a660ea3cb04e19efc04aa63 languageName: node linkType: hard -"@tootallnate/once@npm:1": - version: 1.1.2 - resolution: "@tootallnate/once@npm:1.1.2" - checksum: 10c0/8fe4d006e90422883a4fa9339dd05a83ff626806262e1710cee5758d493e8cbddf2db81c0e4690636dc840b02c9fda62877866ea774ebd07c1777ed5fafbdec6 +"@testcontainers/redis@npm:^10.11.0": + version: 10.13.2 + resolution: "@testcontainers/redis@npm:10.13.2" + dependencies: + testcontainers: "npm:^10.13.2" + checksum: 10c0/668d604b293b1907b417cc40cc2f7f3191b6688474221abdf38166c2000ab516c899234942d0edf9a9df88308fe846321acfa2b7c35c32e459499747ec48f451 languageName: node linkType: hard @@ -1039,7 +1284,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -1071,7 +1316,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.4, @types/babel__traverse@npm:^7.0.6": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": version: 7.20.6 resolution: "@types/babel__traverse@npm:7.20.6" dependencies: @@ -1143,6 +1388,27 @@ __metadata: languageName: node linkType: hard +"@types/docker-modem@npm:*": + version: 3.0.6 + resolution: "@types/docker-modem@npm:3.0.6" + dependencies: + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/d3ffd273148bc883ff9b1a972b1f84c1add6d9a197d2f4fc9774db4c814f39c2e51cc649385b55d781c790c16fb0bf9c1f4c62499bd0f372a4b920190919445d + languageName: node + linkType: hard + +"@types/dockerode@npm:^3.3.29": + version: 3.3.31 + resolution: "@types/dockerode@npm:3.3.31" + dependencies: + "@types/docker-modem": "npm:*" + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/e0b85edcb7065c24d6d8140b90ea3be128451d4ef25d44fe07ef653e931f3ff4ce86ba0fbb0d7037f51296eb5055d17d8d3ac373028c9e13861b3baf249578d8 + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.33": version: 4.19.5 resolution: "@types/express-serve-static-core@npm:4.19.5" @@ -1176,7 +1442,7 @@ __metadata: languageName: node linkType: hard -"@types/graceful-fs@npm:^4.1.2": +"@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" dependencies: @@ -1343,10 +1609,12 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:^2.1.5": - version: 2.7.3 - resolution: "@types/prettier@npm:2.7.3" - checksum: 10c0/0960b5c1115bb25e979009d0b44c42cf3d792accf24085e4bfce15aef5794ea042e04e70c2139a2c3387f781f18c89b5706f000ddb089e9a4a2ccb7536a2c5f0 +"@types/node@npm:^18.11.18": + version: 18.19.59 + resolution: "@types/node@npm:18.19.59" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/6ef007a560b505eea8285f84cd9f689c6ec209ec15462bbc0a5cc9b69d19bcc6e0795ef74cd4edc77aac8d52001a49969f94a3cb4b26ff5987da943b835b9456 languageName: node linkType: hard @@ -1499,6 +1767,34 @@ __metadata: languageName: node linkType: hard +"@types/ssh2-streams@npm:*": + version: 0.1.12 + resolution: "@types/ssh2-streams@npm:0.1.12" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/6c860066e76391c937723b9f8c3953208737be5adf33b5584d7817ec90913094f2ca578e1d47717182f1d62cb5ca8e83fdec0241d73bf064221e3a2b2d132f0e + languageName: node + linkType: hard + +"@types/ssh2@npm:*": + version: 1.15.1 + resolution: "@types/ssh2@npm:1.15.1" + dependencies: + "@types/node": "npm:^18.11.18" + checksum: 10c0/83c83684e0d620ab940e05c5b7e846eacf6c56761e421dbe6a5a51daa09c82fb71ea4843b792b6e6b2edd0bee8eb665034ffd73978d936b9f008c553bcc38ea7 + languageName: node + linkType: hard + +"@types/ssh2@npm:^0.5.48": + version: 0.5.52 + resolution: "@types/ssh2@npm:0.5.52" + dependencies: + "@types/node": "npm:*" + "@types/ssh2-streams": "npm:*" + checksum: 10c0/95c52fd3438dedae6a59ca87b6558cb36568db6b9144c6c8a28c168739e04c51e27c02908aae14950b7b5020e1c40fea039b1203ae2734c356a40a050fd51c84 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -1547,12 +1843,12 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^16.0.0": - version: 16.0.9 - resolution: "@types/yargs@npm:16.0.9" +"@types/yargs@npm:^17.0.8": + version: 17.0.33 + resolution: "@types/yargs@npm:17.0.33" dependencies: "@types/yargs-parser": "npm:*" - checksum: 10c0/be24bd9a56c97ddb2964c1c18f5b9fe8271a50e100dc6945989901aae58f7ce6fb8f3a591c749a518401b6301358dbd1997e83c36138a297094feae7f9ac8211 + checksum: 10c0/d16937d7ac30dff697801c3d6f235be2166df42e4a88bf730fa6dc09201de3727c0a9500c59a672122313341de5f24e45ee0ff579c08ce91928e519090b7906b languageName: node linkType: hard @@ -1684,13 +1980,6 @@ __metadata: languageName: node linkType: hard -"abab@npm:^2.0.3, abab@npm:^2.0.5": - version: 2.0.6 - resolution: "abab@npm:2.0.6" - checksum: 10c0/0b245c3c3ea2598fe0025abf7cc7bb507b06949d51e8edae5d12c1b847a0a0c09639abcb94788332b4e2044ac4491c1e8f571b51c7826fd4b0bda1685ad4a278 - languageName: node - linkType: hard - "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1698,6 +1987,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "accepts@npm:~1.3.5, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -1708,16 +2006,6 @@ __metadata: languageName: node linkType: hard -"acorn-globals@npm:^6.0.0": - version: 6.0.0 - resolution: "acorn-globals@npm:6.0.0" - dependencies: - acorn: "npm:^7.1.1" - acorn-walk: "npm:^7.1.1" - checksum: 10c0/5f92390a3fd7e5a4f84fe976d4650e2a33ecf27135aa9efc5406e3406df7f00a1bbb00648ee0c8058846f55ad0924ff574e6c73395705690e754589380a41801 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -1727,23 +2015,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^7.1.1": - version: 7.2.0 - resolution: "acorn-walk@npm:7.2.0" - checksum: 10c0/ff99f3406ed8826f7d6ef6ac76b7608f099d45a1ff53229fa267125da1924188dbacf02e7903dfcfd2ae4af46f7be8847dc7d564c73c4e230dfb69c8ea8e6b4c - languageName: node - linkType: hard - -"acorn@npm:^7.1.1": - version: 7.4.1 - resolution: "acorn@npm:7.4.1" - bin: - acorn: bin/acorn - checksum: 10c0/bd0b2c2b0f334bbee48828ff897c12bd2eb5898d03bf556dcc8942022cec795ac5bb5b6b585e2de687db6231faf07e096b59a361231dd8c9344d5df5f7f0e526 - languageName: node - linkType: hard - -"acorn@npm:^8.2.4, acorn@npm:^8.9.0": +"acorn@npm:^8.9.0": version: 8.12.1 resolution: "acorn@npm:8.12.1" bin: @@ -1759,15 +2031,6 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:6": - version: 6.0.2 - resolution: "agent-base@npm:6.0.2" - dependencies: - debug: "npm:4" - checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 - languageName: node - linkType: hard - "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.3 resolution: "agent-base@npm:7.1.3" @@ -1869,6 +2132,36 @@ __metadata: languageName: node linkType: hard +"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": + version: 5.0.2 + resolution: "archiver-utils@npm:5.0.2" + dependencies: + glob: "npm:^10.0.0" + graceful-fs: "npm:^4.2.0" + is-stream: "npm:^2.0.1" + lazystream: "npm:^1.0.0" + lodash: "npm:^4.17.15" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/3782c5fa9922186aa1a8e41ed0c2867569faa5f15c8e5e6418ea4c1b730b476e21bd68270b3ea457daf459ae23aaea070b2b9f90cf90a59def8dc79b9e4ef538 + languageName: node + linkType: hard + +"archiver@npm:^7.0.1": + version: 7.0.1 + resolution: "archiver@npm:7.0.1" + dependencies: + archiver-utils: "npm:^5.0.2" + async: "npm:^3.2.4" + buffer-crc32: "npm:^1.0.0" + readable-stream: "npm:^4.0.0" + readdir-glob: "npm:^1.1.2" + tar-stream: "npm:^3.0.0" + zip-stream: "npm:^6.0.1" + checksum: 10c0/02afd87ca16f6184f752db8e26884e6eff911c476812a0e7f7b26c4beb09f06119807f388a8e26ed2558aa8ba9db28646ebd147a4f99e46813b8b43158e1438e + languageName: node + linkType: hard + "argparse@npm:^1.0.7": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -1996,7 +2289,7 @@ __metadata: languageName: node linkType: hard -"asn1@npm:~0.2.3": +"asn1@npm:^0.2.6, asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" dependencies: @@ -2019,6 +2312,13 @@ __metadata: languageName: node linkType: hard +"async-lock@npm:^1.4.1": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 10c0/f696991c7d894af1dc91abc81cc4f14b3785190a35afb1646d8ab91138238d55cabd83bfdd56c42663a008d72b3dc39493ff83797e550effc577d1ccbde254af + languageName: node + linkType: hard + "async@npm:^3.2.3": version: 3.2.5 resolution: "async@npm:3.2.5" @@ -2026,6 +2326,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.4": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -2056,21 +2363,27 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-jest@npm:27.5.1" +"b4a@npm:^1.6.4": + version: 1.6.7 + resolution: "b4a@npm:1.6.7" + checksum: 10c0/ec2f004d1daae04be8c5a1f8aeb7fea213c34025e279db4958eb0b82c1729ee25f7c6e89f92a5f65c8a9cf2d017ce27e3dda912403341d1781bd74528a4849d4 + languageName: node + linkType: hard + +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" dependencies: - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/transform": "npm:^29.7.0" "@types/babel__core": "npm:^7.1.14" babel-plugin-istanbul: "npm:^6.1.1" - babel-preset-jest: "npm:^27.5.1" + babel-preset-jest: "npm:^29.6.3" chalk: "npm:^4.0.0" graceful-fs: "npm:^4.2.9" slash: "npm:^3.0.0" peerDependencies: "@babel/core": ^7.8.0 - checksum: 10c0/3ec8fdabba150431e430ab98d31ba62a1e0bc0fb2fd8d9236cb7dffda740de99c0b04f24da54ff0b5814dce9f81ff0c35a61add53c0734775996a11a7ba38318 + checksum: 10c0/2eda9c1391e51936ca573dd1aedfee07b14c59b33dbe16ef347873ddd777bcf6e2fc739681e9e9661ab54ef84a3109a03725be2ac32cd2124c07ea4401cbe8c1 languageName: node linkType: hard @@ -2087,15 +2400,15 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-plugin-jest-hoist@npm:27.5.1" +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" dependencies: "@babel/template": "npm:^7.3.3" "@babel/types": "npm:^7.3.3" - "@types/babel__core": "npm:^7.0.0" + "@types/babel__core": "npm:^7.1.14" "@types/babel__traverse": "npm:^7.0.6" - checksum: 10c0/2f08ebde32d9d2bffff75524bda44812995b3fcab6cbf259e1db52561b6c8d829f4688db77ef277054a362c9a61826e121a2a4853b0bf93d077ebb3b69685f8e + checksum: 10c0/7e6451caaf7dce33d010b8aafb970e62f1b0c0b57f4978c37b0d457bbcf0874d75a395a102daf0bae0bd14eafb9f6e9a165ee5e899c0a4f1f3bb2e07b304ed2e languageName: node linkType: hard @@ -2121,15 +2434,15 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-preset-jest@npm:27.5.1" +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" dependencies: - babel-plugin-jest-hoist: "npm:^27.5.1" + babel-plugin-jest-hoist: "npm:^29.6.3" babel-preset-current-node-syntax: "npm:^1.0.0" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/fc2f7fd03d8cddb36e0a07a94f1bb1826f7d7dae1f3519ed170c7a5e56c863aecbdb3fd2b034674a53210088478f000318b06415bad511bcf203c5729e5dd079 + checksum: 10c0/ec5fd0276b5630b05f0c14bb97cc3815c6b31600c683ebb51372e54dcb776cff790bdeeabd5b8d01ede375a040337ccbf6a3ccd68d3a34219125945e167ad943 languageName: node linkType: hard @@ -2147,6 +2460,56 @@ __metadata: languageName: node linkType: hard +"bare-events@npm:^2.0.0, bare-events@npm:^2.2.0": + version: 2.5.0 + resolution: "bare-events@npm:2.5.0" + checksum: 10c0/afbeec4e8be4d93fb4a3be65c3b4a891a2205aae30b5a38fafd42976cc76cf30dad348963fe330a0d70186e15dc507c11af42c89af5dddab2a54e5aff02e2896 + languageName: node + linkType: hard + +"bare-fs@npm:^2.1.1": + version: 2.3.5 + resolution: "bare-fs@npm:2.3.5" + dependencies: + bare-events: "npm:^2.0.0" + bare-path: "npm:^2.0.0" + bare-stream: "npm:^2.0.0" + checksum: 10c0/ff18cc9be7c557c38e0342681ba3672ae4b01e5696b567d4035e5995255dc6bc7d4df88ed210fa4d3eb940eb29512e924ebb42814c87fc59a2bee8cf83b7c2f9 + languageName: node + linkType: hard + +"bare-os@npm:^2.1.0": + version: 2.4.4 + resolution: "bare-os@npm:2.4.4" + checksum: 10c0/e7d1a7b2100c05da8d25b60d0d48cf850c6f57064577a3f2f51cf18d417fbcfd6967ed2d8314320914ed69e0f2ebcf54eb1b36092dd172d8e8f969cf8cccf041 + languageName: node + linkType: hard + +"bare-path@npm:^2.0.0, bare-path@npm:^2.1.0": + version: 2.1.3 + resolution: "bare-path@npm:2.1.3" + dependencies: + bare-os: "npm:^2.1.0" + checksum: 10c0/35587e177fc8fa5b13fb90bac8779b5ce49c99016d221ddaefe2232d02bd4295d79b941e14ae19fda75ec42a6fe5fb66c07d83ae7ec11462178e66b7be65ca74 + languageName: node + linkType: hard + +"bare-stream@npm:^2.0.0": + version: 2.3.2 + resolution: "bare-stream@npm:2.3.2" + dependencies: + streamx: "npm:^2.20.0" + checksum: 10c0/e2bda606c2cbd6acbb2558d9a5f6d2d4bc08fb635d32d599bc8e74c1d2298c956decf6a3a820e485a760bb73b8a7f0e743ec5262f08cccbaf5eeb599253d4221 + languageName: node + linkType: hard + +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "basic-auth@npm:~2.0.1": version: 2.0.1 resolution: "basic-auth@npm:2.0.1" @@ -2156,7 +2519,7 @@ __metadata: languageName: node linkType: hard -"bcrypt-pbkdf@npm:^1.0.0": +"bcrypt-pbkdf@npm:^1.0.0, bcrypt-pbkdf@npm:^1.0.2": version: 1.0.2 resolution: "bcrypt-pbkdf@npm:1.0.2" dependencies: @@ -2172,6 +2535,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "bn.js@npm:^4.0.0": version: 4.12.0 resolution: "bn.js@npm:4.12.0" @@ -2227,13 +2601,6 @@ __metadata: languageName: node linkType: hard -"browser-process-hrtime@npm:^1.0.0": - version: 1.0.0 - resolution: "browser-process-hrtime@npm:1.0.0" - checksum: 10c0/65da78e51e9d7fa5909147f269c54c65ae2e03d1cf797cc3cfbbe49f475578b8160ce4a76c36c1a2ffbff26c74f937d73096c508057491ddf1a6dfd11143f72d - languageName: node - linkType: hard - "browserslist@npm:^4.23.1": version: 4.23.2 resolution: "browserslist@npm:4.23.2" @@ -2248,7 +2615,21 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:0.x": +"browserslist@npm:^4.24.0": + version: 4.24.2 + resolution: "browserslist@npm:4.24.2" + dependencies: + caniuse-lite: "npm:^1.0.30001669" + electron-to-chromium: "npm:^1.5.41" + node-releases: "npm:^2.0.18" + update-browserslist-db: "npm:^1.1.1" + bin: + browserslist: cli.js + checksum: 10c0/d747c9fb65ed7b4f1abcae4959405707ed9a7b835639f8a9ba0da2911995a6ab9b0648fd05baf2a4d4e3cf7f9fdbad56d3753f91881e365992c1d49c8d88ff7a + languageName: node + linkType: hard + +"bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -2266,6 +2647,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: 10c0/8b86e161cee4bb48d5fa622cbae4c18f25e4857e5203b89e23de59e627ab26beb82d9d7999f2b8de02580165f61f83f997beaf02980cdf06affd175b651921ab + languageName: node + linkType: hard + "buffer-equal-constant-time@npm:1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -2280,6 +2668,33 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + +"buildcheck@npm:~0.0.6": + version: 0.0.6 + resolution: "buildcheck@npm:0.0.6" + checksum: 10c0/8cbdb89f41bc484b8325f4828db4135b206a0dffb641eb6cdb2b7022483c45dd0e5aac6d820c9a67bdd2caab3a02c76d7ceec7bd9ec494b5a2270d2806b01a76 + languageName: node + linkType: hard + "bull@npm:^4.12.9": version: 4.15.1 resolution: "bull@npm:4.15.1" @@ -2304,6 +2719,13 @@ __metadata: languageName: node linkType: hard +"byline@npm:^5.0.0": + version: 5.0.0 + resolution: "byline@npm:5.0.0" + checksum: 10c0/33fb64cd84440b3652a99a68d732c56ef18a748ded495ba38e7756a242fab0d4654b9b8ce269fd0ac14c5f97aa4e3c369613672b280a1f60b559b34223105c85 + languageName: node + linkType: hard + "byte-length@npm:^1.0.2": version: 1.0.2 resolution: "byte-length@npm:1.0.2" @@ -2386,6 +2808,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001669": + version: 1.0.30001669 + resolution: "caniuse-lite@npm:1.0.30001669" + checksum: 10c0/f125f23440d3dbb6c25ffb8d55f4ce48af36a84d0932b152b3b74f143a4170cbe92e02b0a9676209c86609bf7bf34119ff10cc2bc7c1b7ea40e936cc16598408 + languageName: node + linkType: hard + "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -2404,7 +2833,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": +"chalk@npm:^4.0.0, chalk@npm:^4.0.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2440,6 +2869,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -2514,14 +2950,14 @@ __metadata: languageName: node linkType: hard -"cliui@npm:^7.0.2": - version: 7.0.4 - resolution: "cliui@npm:7.0.4" +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" dependencies: string-width: "npm:^4.2.0" - strip-ansi: "npm:^6.0.0" + strip-ansi: "npm:^6.0.1" wrap-ansi: "npm:^7.0.0" - checksum: 10c0/6035f5daf7383470cef82b3d3db00bec70afb3423538c50394386ffbbab135e26c3689c41791f911fa71b62d13d3863c712fdd70f0fbdffd938a1e6fd09aac00 + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 languageName: node linkType: hard @@ -2638,6 +3074,19 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^6.0.2": + version: 6.0.2 + resolution: "compress-commons@npm:6.0.2" + dependencies: + crc-32: "npm:^1.2.0" + crc32-stream: "npm:^6.0.0" + is-stream: "npm:^2.0.1" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/2347031b7c92c8ed5011b07b93ec53b298fa2cd1800897532ac4d4d1aeae06567883f481b6e35f13b65fc31b190c751df6635434d525562f0203fde76f1f0814 + languageName: node + linkType: hard + "compressible@npm:~2.0.16": version: 2.0.18 resolution: "compressible@npm:2.0.18" @@ -2711,13 +3160,6 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0": - version: 1.9.0 - resolution: "convert-source-map@npm:1.9.0" - checksum: 10c0/281da55454bf8126cbc6625385928c43479f2060984180c42f3a86c8b8c12720a24eac260624a7d1e090004028d2dee78602330578ceec1a08e27cb8bb0a8a5b - languageName: node - linkType: hard - "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -2763,6 +3205,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.7.2": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -2799,70 +3248,83 @@ __metadata: languageName: node linkType: hard -"cron-parser@npm:^4.2.1": - version: 4.9.0 - resolution: "cron-parser@npm:4.9.0" +"cpu-features@npm:~0.0.10": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" dependencies: - luxon: "npm:^3.2.1" - checksum: 10c0/348622bdcd1a15695b61fc33af8a60133e5913a85cf99f6344367579e7002896514ba3b0a9d6bb569b02667d6b06836722bf2295fcd101b3de378f71d37bed0b + buildcheck: "npm:~0.0.6" + nan: "npm:^2.19.0" + node-gyp: "npm:latest" + checksum: 10c0/0c4a12904657b22477ffbcfd2b4b2bdd45b174f283616b18d9e1ade495083f9f6098493feb09f4ae2d0b36b240f9ecd32cfb4afe210cf0d0f8f0cc257bd58e54 languageName: node linkType: hard -"cross-spawn@npm:^6.0.5": - version: 6.0.5 - resolution: "cross-spawn@npm:6.0.5" - dependencies: - nice-try: "npm:^1.0.4" - path-key: "npm:^2.0.1" - semver: "npm:^5.5.0" - shebang-command: "npm:^1.2.0" - which: "npm:^1.2.9" - checksum: 10c0/e05544722e9d7189b4292c66e42b7abeb21db0d07c91b785f4ae5fefceb1f89e626da2703744657b287e86dcd4af57b54567cef75159957ff7a8a761d9055012 +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: 10c0/11dcf4a2e77ee793835d49f2c028838eae58b44f50d1ff08394a610bfd817523f105d6ae4d9b5bef0aad45510f633eb23c903e9902e4409bed1ce70cb82b9bf0 languageName: node linkType: hard -"cross-spawn@npm:^7.0.0": - version: 7.0.6 - resolution: "cross-spawn@npm:7.0.6" +"crc32-stream@npm:^6.0.0": + version: 6.0.0 + resolution: "crc32-stream@npm:6.0.0" dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 + crc-32: "npm:^1.2.0" + readable-stream: "npm:^4.0.0" + checksum: 10c0/bf9c84571ede2d119c2b4f3a9ef5eeb9ff94b588493c0d3862259af86d3679dcce1c8569dd2b0a6eff2f35f5e2081cc1263b846d2538d4054da78cf34f262a3d languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f languageName: node linkType: hard -"cssom@npm:^0.4.4": - version: 0.4.4 - resolution: "cssom@npm:0.4.4" - checksum: 10c0/0d4fc70255ea3afbd4add79caffa3b01720929da91105340600d8c0f06c31716f933c6314c3d43b62b57c9637bc2eb35296a9e2db427e8b572ee38a4be2b5f82 +"cron-parser@npm:^4.2.1": + version: 4.9.0 + resolution: "cron-parser@npm:4.9.0" + dependencies: + luxon: "npm:^3.2.1" + checksum: 10c0/348622bdcd1a15695b61fc33af8a60133e5913a85cf99f6344367579e7002896514ba3b0a9d6bb569b02667d6b06836722bf2295fcd101b3de378f71d37bed0b languageName: node linkType: hard -"cssom@npm:~0.3.6": - version: 0.3.8 - resolution: "cssom@npm:0.3.8" - checksum: 10c0/d74017b209440822f9e24d8782d6d2e808a8fdd58fa626a783337222fe1c87a518ba944d4c88499031b4786e68772c99dfae616638d71906fe9f203aeaf14411 +"cross-spawn@npm:^6.0.5": + version: 6.0.5 + resolution: "cross-spawn@npm:6.0.5" + dependencies: + nice-try: "npm:^1.0.4" + path-key: "npm:^2.0.1" + semver: "npm:^5.5.0" + shebang-command: "npm:^1.2.0" + which: "npm:^1.2.9" + checksum: 10c0/e05544722e9d7189b4292c66e42b7abeb21db0d07c91b785f4ae5fefceb1f89e626da2703744657b287e86dcd4af57b54567cef75159957ff7a8a761d9055012 languageName: node linkType: hard -"cssstyle@npm:^2.3.0": - version: 2.3.0 - resolution: "cssstyle@npm:2.3.0" +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" dependencies: - cssom: "npm:~0.3.6" - checksum: 10c0/863400da2a458f73272b9a55ba7ff05de40d850f22eb4f37311abebd7eff801cf1cd2fb04c4c92b8c3daed83fe766e52e4112afb7bc88d86c63a9c2256a7d178 + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 languageName: node linkType: hard @@ -2882,17 +3344,6 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^2.0.0": - version: 2.0.0 - resolution: "data-urls@npm:2.0.0" - dependencies: - abab: "npm:^2.0.3" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.0.0" - checksum: 10c0/1246442178eb756afb1d99e54669a119eafb3e69c73300d14089687c50c64f9feadd93c973f496224a12f89daa94267a6114aecd70e9b279c09d908c5be44d01 - languageName: node - linkType: hard - "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -2956,10 +3407,15 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.2.1": - version: 10.4.3 - resolution: "decimal.js@npm:10.4.3" - checksum: 10c0/6d60206689ff0911f0ce968d40f163304a6c1bc739927758e6efc7921cfa630130388966f16bf6ef6b838cb33679fbe8e7a78a2f3c478afce841fd55ac8fb8ee +"debug@npm:^4.3.5": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b languageName: node linkType: hard @@ -2970,10 +3426,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^0.7.0": - version: 0.7.0 - resolution: "dedent@npm:0.7.0" - checksum: 10c0/7c3aa00ddfe3e5fcd477958e156156a5137e3bb6ff1493ca05edff4decf29a90a057974cc77e75951f8eb801c1816cb45aea1f52d628cdd000b82b36ab839d1b +"dedent@npm:^1.0.0": + version: 1.5.3 + resolution: "dedent@npm:1.5.3" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10c0/d94bde6e6f780be4da4fd760288fcf755ec368872f4ac5218197200d86430aeb8d90a003a840bff1c20221188e3f23adced0119cb811c6873c70d0ac66d12832 languageName: node linkType: hard @@ -3072,6 +3533,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -3081,6 +3549,38 @@ __metadata: languageName: node linkType: hard +"docker-compose@npm:^0.24.8": + version: 0.24.8 + resolution: "docker-compose@npm:0.24.8" + dependencies: + yaml: "npm:^2.2.2" + checksum: 10c0/1494389e554fed8aabf9fef24210a641cd2442028b1462d7f68186919f5e75045f7bfb4ccaf47c94ed879dcb63e4d82885c389399f531550c4b244920740b2b3 + languageName: node + linkType: hard + +"docker-modem@npm:^3.0.0": + version: 3.0.8 + resolution: "docker-modem@npm:3.0.8" + dependencies: + debug: "npm:^4.1.1" + readable-stream: "npm:^3.5.0" + split-ca: "npm:^1.0.1" + ssh2: "npm:^1.11.0" + checksum: 10c0/5c00592297fabd78454621fe765a5ef0daea4bbb6692e239ad65b111f4da9d750178f448f8efcaf84f9f999598eb735bc14ad6bf5f0a2dcf9c2d453d5b683540 + languageName: node + linkType: hard + +"dockerode@npm:^3.3.5": + version: 3.3.5 + resolution: "dockerode@npm:3.3.5" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + docker-modem: "npm:^3.0.0" + tar-fs: "npm:~2.0.1" + checksum: 10c0/c45fa8ed3ad76f13fe7799d539a60fe466f8e34bea06b30d75be9e08bc00536cc9ff2d54e38fbb3b2a8a382bf9d4459a27741e6454ce7d0cda5cd35c51224c73 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -3108,15 +3608,6 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^2.0.1": - version: 2.0.1 - resolution: "domexception@npm:2.0.1" - dependencies: - webidl-conversions: "npm:^5.0.0" - checksum: 10c0/24a3a07b85420671bc805ead7305e0f2ec9e55f104889b64c5a9fa7d93681e514f05c65f947bd9401b3da67f77b92fe7861bd15f4d0d418c4d32e34a2cd55d38 - languageName: node - linkType: hard - "dotenv@npm:^16.0.0": version: 16.4.5 resolution: "dotenv@npm:16.4.5" @@ -3157,6 +3648,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10c0/52eade9e68416ed04f7f92c492183340582a36482836b11eab97b159fcdcfdedc62233a1bf0bf5e5e1851c501f2dca0e2e9afd111db2599e4e7f53ee29429ae1 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.820": version: 1.4.829 resolution: "electron-to-chromium@npm:1.4.829" @@ -3164,10 +3666,17 @@ __metadata: languageName: node linkType: hard -"emittery@npm:^0.8.1": - version: 0.8.1 - resolution: "emittery@npm:0.8.1" - checksum: 10c0/1302868b6e258909964339f28569b97658d75c1030271024ac2f50f84957eab6a6a04278861a9c1d47131b9dfb50f25a5d017750d1c99cd86763e19a93b838bf +"electron-to-chromium@npm:^1.5.41": + version: 1.5.45 + resolution: "electron-to-chromium@npm:1.5.45" + checksum: 10c0/f361ceda3bedcdc531ec0c060759c3487efd894d16a379beffe82a372fbeadcd1ac3cfc74a103b946dd2d12923a547289916743a609adaf68e5c4eef806e9e49 + languageName: node + linkType: hard + +"emittery@npm:^0.13.1": + version: 0.13.1 + resolution: "emittery@npm:0.13.1" + checksum: 10c0/1573d0ae29ab34661b6c63251ff8f5facd24ccf6a823f19417ae8ba8c88ea450325788c67f16c99edec8de4b52ce93a10fe441ece389fd156e88ee7dab9bfa35 languageName: node linkType: hard @@ -3208,6 +3717,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -3348,6 +3866,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + "escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" @@ -3376,24 +3901,6 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^2.0.0": - version: 2.1.0 - resolution: "escodegen@npm:2.1.0" - dependencies: - esprima: "npm:^4.0.1" - estraverse: "npm:^5.2.0" - esutils: "npm:^2.0.2" - source-map: "npm:~0.6.1" - dependenciesMeta: - source-map: - optional: true - bin: - escodegen: bin/escodegen.js - esgenerate: bin/esgenerate.js - checksum: 10c0/e1450a1f75f67d35c061bf0d60888b15f62ab63aef9df1901cffc81cffbbb9e8b3de237c5502cf8613a017c1df3a3003881307c78835a1ab54d8c8d2206e01d3 - languageName: node - linkType: hard - "eslint-config-airbnb-base@npm:^15.0.0": version: 15.0.0 resolution: "eslint-config-airbnb-base@npm:15.0.0" @@ -3621,7 +4128,7 @@ __metadata: languageName: node linkType: hard -"esprima@npm:^4.0.0, esprima@npm:^4.0.1": +"esprima@npm:^4.0.0": version: 4.0.1 resolution: "esprima@npm:4.0.1" bin: @@ -3677,6 +4184,20 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + "execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -3701,15 +4222,16 @@ __metadata: languageName: node linkType: hard -"expect@npm:^27.5.1": - version: 27.5.1 - resolution: "expect@npm:27.5.1" +"expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - checksum: 10c0/020e237c7191a584bc25a98181c3969cdd62fa1c044e4d81d5968e24075f39bc2349fcee48de82431033823b525e7cf5ac410b253b3115392f1026cb27258811 + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/2eddeace66e68b8d8ee5f7be57f3014b19770caaf6815c7a08d131821da527fb8c8cb7b3dcd7c883d2d3d8d184206a4268984618032d1e4b16dc8d6596475d41 languageName: node linkType: hard @@ -3720,11 +4242,11 @@ __metadata: languageName: node linkType: hard -"express-session@npm:^1.17.2": - version: 1.18.0 - resolution: "express-session@npm:1.18.0" +"express-session@npm:^1.18.1": + version: 1.18.1 + resolution: "express-session@npm:1.18.1" dependencies: - cookie: "npm:0.6.0" + cookie: "npm:0.7.2" cookie-signature: "npm:1.0.7" debug: "npm:2.6.9" depd: "npm:~2.0.0" @@ -3732,7 +4254,7 @@ __metadata: parseurl: "npm:~1.3.3" safe-buffer: "npm:5.2.1" uid-safe: "npm:~2.1.5" - checksum: 10c0/5c3f1237f2789cf32f9cd668d3217c228916edfd3b5a686a894a80c7cca63f9ef66bb86a8457074b9b4cc4b2ee97e16781dd4e0cff7829b671ab0db5da0db638 + checksum: 10c0/7999f128df1528430044c97bb1aac95093afaee86c5fa54b2890c4aad9898d79745301f8c90c2df057d6dfe7af7f8ee220340bf5eb53dca5eff37e52cc2fbec7 languageName: node linkType: hard @@ -3810,6 +4332,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 10c0/d53f6f786875e8b0529f784b59b4b05d4b5c31c651710496440006a398389a579c8dbcd2081311478b5bf77f4b0b21de69109c5a4eabea9d8e8783d1eb864e4c + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" @@ -3823,7 +4352,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0": +"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b @@ -3898,6 +4427,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10c0/426b1de3944a3d153b053f1c0ebfd02dccd0308a4f9e832ad220707a6d1f1b3c9784d6cadf6b2f68f09a57565f63ebc7bcdc913ccf8012d834f472c46e596f41 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -4011,17 +4549,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^3.0.0": - version: 3.0.1 - resolution: "form-data@npm:3.0.1" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - mime-types: "npm:^2.1.12" - checksum: 10c0/1ccc3ae064a080a799923f754d49fcebdd90515a8924f0f54de557540b50e7f1fe48ba5f2bd0435a5664aa2d49729107e6aaf2155a9abf52339474c5638b4485 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -4070,6 +4597,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 + languageName: node + linkType: hard + "fs-extra@npm:^8.0.1": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -4235,7 +4769,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": +"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -4251,7 +4785,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -4314,7 +4848,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -4458,15 +4992,6 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^2.0.1": - version: 2.0.1 - resolution: "html-encoding-sniffer@npm:2.0.1" - dependencies: - whatwg-encoding: "npm:^1.0.5" - checksum: 10c0/6dc3aa2d35a8f0c8c7906ffb665dd24a88f7004f913fafdd3541d24a4da6182ab30c4a0a81387649a1234ecb90182c4136220ed12ae3dc1a57ed68e533dea416 - languageName: node - linkType: hard - "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -4494,17 +5019,6 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^4.0.1": - version: 4.0.1 - resolution: "http-proxy-agent@npm:4.0.1" - dependencies: - "@tootallnate/once": "npm:1" - agent-base: "npm:6" - debug: "npm:4" - checksum: 10c0/4fa4774d65b5331814b74ac05cefea56854fc0d5989c80b13432c1b0d42a14c9f4342ca3ad9f0359a52e78da12b1744c9f8a28e50042136ea9171675d972a5fd - languageName: node - linkType: hard - "http-proxy-agent@npm:^7.0.0": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -4526,16 +5040,6 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": - version: 5.0.1 - resolution: "https-proxy-agent@npm:5.0.1" - dependencies: - agent-base: "npm:6" - debug: "npm:4" - checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 - languageName: node - linkType: hard - "https-proxy-agent@npm:^7.0.1": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -4580,6 +5084,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore-by-default@npm:^1.0.1": version: 1.0.1 resolution: "ignore-by-default@npm:1.0.1" @@ -4640,7 +5151,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -4861,13 +5372,6 @@ __metadata: languageName: node linkType: hard -"is-potential-custom-element-name@npm:^1.0.1": - version: 1.0.1 - resolution: "is-potential-custom-element-name@npm:1.0.1" - checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 - languageName: node - linkType: hard - "is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" @@ -4887,7 +5391,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": +"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 @@ -4986,7 +5490,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0": +"istanbul-lib-instrument@npm:^5.0.4": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: @@ -4999,6 +5503,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.3 + resolution: "istanbul-lib-instrument@npm:6.0.3" + dependencies: + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10c0/a1894e060dd2a3b9f046ffdc87b44c00a35516f5e6b7baf4910369acca79e506fc5323a816f811ae23d82334b38e3ddeb8b3b331bd2c860540793b59a8689128 + languageName: node + linkType: hard + "istanbul-lib-report@npm:^3.0.0": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" @@ -5044,60 +5561,74 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-changed-files@npm:27.5.1" +"jake@npm:^10.8.5": + version: 10.9.2 + resolution: "jake@npm:10.9.2" + dependencies: + async: "npm:^3.2.3" + chalk: "npm:^4.0.2" + filelist: "npm:^1.0.4" + minimatch: "npm:^3.1.2" + bin: + jake: bin/cli.js + checksum: 10c0/c4597b5ed9b6a908252feab296485a4f87cba9e26d6c20e0ca144fb69e0c40203d34a2efddb33b3d297b8bd59605e6c1f44f6221ca1e10e69175ecbf3ff5fe31 + languageName: node + linkType: hard + +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" execa: "npm:^5.0.0" - throat: "npm:^6.0.1" - checksum: 10c0/ee2e663da669a1f8a1452626c71b9691a34cc6789bbf6cb04ef4430a63301db806039e93dd5c9cc6c0caa3d3f250ff18ed51e058fc3533a71f73e24f41b5d1bd + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10c0/e071384d9e2f6bb462231ac53f29bff86f0e12394c1b49ccafbad225ce2ab7da226279a8a94f421949920bef9be7ef574fd86aee22e8adfa149be73554ab828b languageName: node linkType: hard -"jest-circus@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-circus@npm:27.5.1" +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" co: "npm:^4.6.0" - dedent: "npm:^0.7.0" - expect: "npm:^27.5.1" + dedent: "npm:^1.0.0" is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" slash: "npm:^3.0.0" stack-utils: "npm:^2.0.3" - throat: "npm:^6.0.1" - checksum: 10c0/195b88ff6c74a1ad0f2386bea25700e884f32e05be9211bc197b960e7553a952ab38aff9aafb057c6a92eaa85bde2804e01244278a477b80a99e11f890ee15d9 + checksum: 10c0/8d15344cf7a9f14e926f0deed64ed190c7a4fa1ed1acfcd81e4cc094d3cc5bf7902ebb7b874edc98ada4185688f90c91e1747e0dfd7ac12463b097968ae74b5e languageName: node linkType: hard -"jest-cli@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-cli@npm:27.5.1" +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" dependencies: - "@jest/core": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" import-local: "npm:^3.0.2" - jest-config: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - prompts: "npm:^2.0.1" - yargs: "npm:^16.2.0" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -5105,44 +5636,45 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 10c0/45abaafbe1a01ea4c48953c85d42c961b6e33ef5847e10642713cde97761611b0af56d5a0dcb82abf19c500c6e9b680222a7f953b437e5760ba584521b74f9ea + checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a languageName: node linkType: hard -"jest-config@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-config@npm:27.5.1" +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.8.0" - "@jest/test-sequencer": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - babel-jest: "npm:^27.5.1" + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" chalk: "npm:^4.0.0" ci-info: "npm:^3.2.0" deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.1" + glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-jasmine2: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" micromatch: "npm:^4.0.4" parse-json: "npm:^5.2.0" - pretty-format: "npm:^27.5.1" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" strip-json-comments: "npm:^3.1.1" peerDependencies: + "@types/node": "*" ts-node: ">=9.0.0" peerDependenciesMeta: + "@types/node": + optional: true ts-node: optional: true - checksum: 10c0/28867b165f0e25b711a2ade5f261a1b1606b476704ff68a50688eaf3b9c853f69542645cc7e0dab38079ed74e3acc99e38628faf736c1739e44fc869c62c6051 + checksum: 10c0/bab23c2eda1fff06e0d104b00d6adfb1d1aabb7128441899c9bff2247bd26710b050a5364281ce8d52b46b499153bf7e3ee88b19831a8f3451f1477a0246a0f1 languageName: node linkType: hard @@ -5158,54 +5690,51 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-docblock@npm:27.5.1" +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" dependencies: - detect-newline: "npm:^3.0.0" - checksum: 10c0/0ce3661a9152497b3a766996eda42edeab51f676fa57ec414a0168fef2a9b1784d056879281c22bca2875c9e63d41327cac0749a8c6e205330e13fcfe0e40316 + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 languageName: node linkType: hard -"jest-each@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-each@npm:27.5.1" +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10c0/e382f677e69c15aa906ec0ae2d3d944aa948ce338b2bbcb480b76c16eb12cc2141d78edda48c510363e3b2c507cc2140569c3a163c64ffa34e14cc6a8b37fb81 + detect-newline: "npm:^3.0.0" + checksum: 10c0/d932a8272345cf6b6142bb70a2bb63e0856cc0093f082821577ea5bdf4643916a98744dfc992189d2b1417c38a11fa42466f6111526bc1fb81366f56410f3be9 languageName: node linkType: hard -"jest-environment-jsdom@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-jsdom@npm:27.5.1" +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jsdom: "npm:^16.6.0" - checksum: 10c0/ea759ffa43e96d773983a4172c32c1a3774907723564a30a001c8a85d22d9ed82f6c45329a514152744e8916379c1c4cf9e527297ecfa1e8a4cc4888141b38fd + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10c0/f7f9a90ebee80cc688e825feceb2613627826ac41ea76a366fa58e669c3b2403d364c7c0a74d862d469b103c843154f8456d3b1c02b487509a12afa8b59edbb4 languageName: node linkType: hard -"jest-environment-node@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-node@npm:27.5.1" +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10c0/3bbc31545436c6bb4a18841241e62036382a7261b9bb8cdc2823ec942a8a3053f98219b3ec2a4a7920bfba347602c16dd16767d9fece915134aee2e30091165c + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/61f04fec077f8b1b5c1a633e3612fc0c9aa79a0ab7b05600683428f1e01a4d35346c474bde6f439f9fcc1a4aa9a2861ff852d079a43ab64b02105d1004b2592b languageName: node linkType: hard @@ -5216,101 +5745,95 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-haste-map@npm:27.5.1" +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10c0/552e7a97a983d3c2d4e412a44eb7de0430ff773dd99f7500962c268d6dfbfa431d7d08f919c9d960530e5f7f78eb47f267ad9b318265e5092b3ff9ede0db7c2b + languageName: node + linkType: hard + +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - "@types/graceful-fs": "npm:^4.1.2" + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" "@types/node": "npm:*" anymatch: "npm:^3.0.3" fb-watchman: "npm:^2.0.0" fsevents: "npm:^2.3.2" graceful-fs: "npm:^4.2.9" - jest-regex-util: "npm:^27.5.1" - jest-serializer: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" micromatch: "npm:^4.0.4" - walker: "npm:^1.0.7" + walker: "npm:^1.0.8" dependenciesMeta: fsevents: optional: true - checksum: 10c0/831ae476fddc6babe64ea3e7f91b4ccee0371c03ec88af5a615023711866abdd496b51344f47c4d02b6b47b433367ca41e9e42d79527b39afec767e8be9e8a63 + checksum: 10c0/2683a8f29793c75a4728787662972fedd9267704c8f7ef9d84f2beed9a977f1cf5e998c07b6f36ba5603f53cb010c911fe8cd0ac9886e073fe28ca66beefd30c languageName: node linkType: hard -"jest-jasmine2@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-jasmine2@npm:27.5.1" +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - expect: "npm:^27.5.1" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - throat: "npm:^6.0.1" - checksum: 10c0/028172d5d65abf7e8da89c30894112efdd18007a934f30b89e3f35def3764824a9680917996d5e551caa2087589a372a2539777d5554fa3bae6c7e36afec6d4c + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/71bb9f77fc489acb842a5c7be030f2b9acb18574dc9fb98b3100fc57d422b1abc55f08040884bd6e6dbf455047a62f7eaff12aa4058f7cbdc11558718ca6a395 languageName: node linkType: hard -"jest-leak-detector@npm:^27.5.1": +"jest-matcher-utils@npm:^27.0.0": version: 27.5.1 - resolution: "jest-leak-detector@npm:27.5.1" + resolution: "jest-matcher-utils@npm:27.5.1" dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^27.5.1" jest-get-type: "npm:^27.5.1" pretty-format: "npm:^27.5.1" - checksum: 10c0/33ec88ab7d76931ae0a03b18186234114e42a4e9fae748f8a197f7f85b884c2e92ea692c06704b8a469ac26b9c6411a7a1bbc8d34580ed56672a7f6be2681aee + checksum: 10c0/a2f082062e8bedc9cfe2654177a894ca43768c6db4c0f4efc0d6ec195e305a99e3d868ff54cc61bcd7f1c810d8ee28c9ac6374de21715dc52f136876de739a73 languageName: node linkType: hard -"jest-matcher-utils@npm:^27.0.0, jest-matcher-utils@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-matcher-utils@npm:27.5.1" +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" dependencies: chalk: "npm:^4.0.0" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10c0/a2f082062e8bedc9cfe2654177a894ca43768c6db4c0f4efc0d6ec195e305a99e3d868ff54cc61bcd7f1c810d8ee28c9ac6374de21715dc52f136876de739a73 + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e languageName: node linkType: hard -"jest-message-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-message-util@npm:27.5.1" +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" dependencies: "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/stack-utils": "npm:^2.0.0" chalk: "npm:^4.0.0" graceful-fs: "npm:^4.2.9" micromatch: "npm:^4.0.4" - pretty-format: "npm:^27.5.1" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" stack-utils: "npm:^2.0.3" - checksum: 10c0/447c99061006949bd0c5ac3fcf4dfad11e763712ada1b3df1c1f276d1d4f55b3f7a8bee27591cd1fe23b56220830b2a74f321925d345374d1b7cf9cd536f19b5 + checksum: 10c0/850ae35477f59f3e6f27efac5215f706296e2104af39232bb14e5403e067992afb5c015e87a9243ec4d9df38525ef1ca663af9f2f4766aa116f127247008bd22 languageName: node linkType: hard -"jest-mock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-mock@npm:27.5.1" +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - checksum: 10c0/6ad58454b37ee3f726930b07efbf40a7c79d2d2d9c7b226708b4b550bc0904de93bcacf714105d11952a5c0bc855e5d59145c8c9dbbb4e69b46e7367abf53b52 + jest-util: "npm:^29.7.0" + checksum: 10c0/7b9f8349ee87695a309fe15c46a74ab04c853369e5c40952d68061d9dc3159a0f0ed73e215f81b07ee97a9faaf10aebe5877a9d6255068a0977eae6a9ff1d5ac languageName: node linkType: hard @@ -5326,202 +5849,191 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-regex-util@npm:27.5.1" - checksum: 10c0/f9790d417b667b38155c4bbd58f2afc0ad9f774381e5358776df02df3f29564069d4773c7ba050db6826bad8a4cc7ef82c3b4c65bfa508e419fdd063a9682c42 +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 10c0/4e33fb16c4f42111159cafe26397118dcfc4cf08bc178a67149fb05f45546a91928b820894572679d62559839d0992e21080a1527faad65daaae8743a5705a3b languageName: node linkType: hard -"jest-resolve-dependencies@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve-dependencies@npm:27.5.1" +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - checksum: 10c0/06ba847f9386b0c198bb033a2041fac141dec443ae3c60acdc3426c1844aa4c942770f8f272a1f54686979894e389bc7774d4123bb3a0fbfabe02b7deef9ef62 + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b6e9ad8ae5b6049474118ea6441dfddd385b6d1fc471db0136f7c8fbcfe97137a9665e4f837a9f49f15a29a1deb95a14439b7aec812f3f99d08f228464930f0d languageName: node linkType: hard -"jest-resolve@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve@npm:27.5.1" +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" chalk: "npm:^4.0.0" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" + jest-haste-map: "npm:^29.7.0" jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" resolve: "npm:^1.20.0" - resolve.exports: "npm:^1.1.0" + resolve.exports: "npm:^2.0.0" slash: "npm:^3.0.0" - checksum: 10c0/5f9577e424346881964683f22472bd12bd9cfd70e49cb1800ccd31f2e88b0985ed353ca5cc7fb02de9093be2c733ab32de526c99a1192455ddb167afe916efd1 + checksum: 10c0/59da5c9c5b50563e959a45e09e2eace783d7f9ac0b5dcc6375dea4c0db938d2ebda97124c8161310082760e8ebbeff9f6b177c15ca2f57fb424f637a5d2adb47 languageName: node linkType: hard -"jest-runner@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runner@npm:27.5.1" +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" + emittery: "npm:^0.13.1" graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-leak-detector: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - source-map-support: "npm:^0.5.6" - throat: "npm:^6.0.1" - checksum: 10c0/b79962003c641eaabe4fa8855ee2127009c48f929dfca67f7fbdbc3fe84ea827964d5cbfcfd791405448011014172ea8c4faffe3669a148824ef4fac37838fe8 - languageName: node - linkType: hard - -"jest-runtime@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runtime@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/globals": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10c0/2194b4531068d939f14c8d3274fe5938b77fa73126aedf9c09ec9dec57d13f22c72a3b5af01ac04f5c1cf2e28d0ac0b4a54212a61b05f10b5d6b47f2a1097bb4 + languageName: node + linkType: hard + +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" chalk: "npm:^4.0.0" cjs-module-lexer: "npm:^1.0.0" collect-v8-coverage: "npm:^1.0.0" - execa: "npm:^5.0.0" glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" strip-bom: "npm:^4.0.0" - checksum: 10c0/22ec24f4b928bdbdb7415ae7470ef523a6379812b8d0500d4d2f2124107d3af2c8fb99842352e320e79a47508a017dd5ab4b713270ad04ba9144c1961672ce29 - languageName: node - linkType: hard - -"jest-serializer@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-serializer@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - graceful-fs: "npm:^4.2.9" - checksum: 10c0/7a2b634a5a044b3ccf912a17032338309c90b50831a2e500f963b25e9a4ce9b550a1af1fb64f7c9a271ed6a1f951fca37bd0d61a0b286aefe197812193b0d825 + checksum: 10c0/7cd89a1deda0bda7d0941835434e44f9d6b7bd50b5c5d9b0fc9a6c990b2d4d2cab59685ab3cb2850ed4cc37059f6de903af5a50565d7f7f1192a77d3fd6dd2a6 languageName: node linkType: hard -"jest-snapshot@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-snapshot@npm:27.5.1" +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.7.2" + "@babel/core": "npm:^7.11.6" "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/traverse": "npm:^7.7.2" - "@babel/types": "npm:^7.0.0" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/babel__traverse": "npm:^7.0.4" - "@types/prettier": "npm:^2.1.5" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" babel-preset-current-node-syntax: "npm:^1.0.0" chalk: "npm:^4.0.0" - expect: "npm:^27.5.1" + expect: "npm:^29.7.0" graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" natural-compare: "npm:^1.4.0" - pretty-format: "npm:^27.5.1" - semver: "npm:^7.3.2" - checksum: 10c0/819ed445a749065efdfb7c3a5befb9331e550930acdcb8cbe49d5e64a1f05451a91094550aae6840e17afeeefc3660f205f2a7ba780fa0d0ebfa5dcfb1345f15 + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10c0/6e9003c94ec58172b4a62864a91c0146513207bedf4e0a06e1e2ac70a4484088a2683e3a0538d8ea913bcfd53dc54a9b98a98cdfa562e7fe1d1339aeae1da570 languageName: node linkType: hard -"jest-util@npm:^27.0.0, jest-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-util@npm:27.5.1" +"jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-util@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" ci-info: "npm:^3.2.0" graceful-fs: "npm:^4.2.9" picomatch: "npm:^2.2.3" - checksum: 10c0/0f60cd2a2e09a6646ccd4ff489f1970282c0694724104979e897bd5164f91204726f5408572bf5e759d09e59d5c4e4dc65a643d2b630e06a10402bba07bf2a2e + checksum: 10c0/bc55a8f49fdbb8f51baf31d2a4f312fb66c9db1483b82f602c9c990e659cdd7ec529c8e916d5a89452ecbcfae4949b21b40a7a59d4ffc0cd813a973ab08c8150 languageName: node linkType: hard -"jest-validate@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-validate@npm:27.5.1" +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" camelcase: "npm:^6.2.0" chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" + jest-get-type: "npm:^29.6.3" leven: "npm:^3.1.0" - pretty-format: "npm:^27.5.1" - checksum: 10c0/ac5aa45b3ce798e450eda33764fa6d8c75f8794f92005e596928a78847b6013c5a6198ca2c2b4097a9315befb3868d12a52fbe7e6945cc85f81cb824d87c5c59 + pretty-format: "npm:^29.7.0" + checksum: 10c0/a20b930480c1ed68778c739f4739dce39423131bc070cd2505ddede762a5570a256212e9c2401b7ae9ba4d7b7c0803f03c5b8f1561c62348213aba18d9dbece2 languageName: node linkType: hard -"jest-watcher@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-watcher@npm:27.5.1" +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" dependencies: - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" ansi-escapes: "npm:^4.2.1" chalk: "npm:^4.0.0" - jest-util: "npm:^27.5.1" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" string-length: "npm:^4.0.1" - checksum: 10c0/e42f5e38bc4da56bde6ccec4b13b7646460a3d6b567934e0ca96d72c2ce837223ffbb84a2f8428197da4323870c03f00969237f9b40f83a3072111a8cd66cc4b + checksum: 10c0/ec6c75030562fc8f8c727cb8f3b94e75d831fc718785abfc196e1f2a2ebc9a2e38744a15147170039628a853d77a3b695561ce850375ede3a4ee6037a2574567 languageName: node linkType: hard -"jest-worker@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-worker@npm:27.5.1" +"jest-worker@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-worker@npm:29.7.0" dependencies: "@types/node": "npm:*" + jest-util: "npm:^29.7.0" merge-stream: "npm:^2.0.0" supports-color: "npm:^8.0.0" - checksum: 10c0/8c4737ffd03887b3c6768e4cc3ca0269c0336c1e4b1b120943958ddb035ed2a0fc6acab6dc99631720a3720af4e708ff84fb45382ad1e83c27946adf3623969b + checksum: 10c0/5570a3a005b16f46c131968b8a5b56d291f9bbb85ff4217e31c80bd8a02e7de799e59a54b95ca28d5c302f248b54cbffde2d177c2f0f52ffcee7504c6eabf660 languageName: node linkType: hard -"jest@npm:^27.5.1": - version: 27.5.1 - resolution: "jest@npm:27.5.1" +"jest@npm:29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" dependencies: - "@jest/core": "npm:^27.5.1" + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" import-local: "npm:^3.0.2" - jest-cli: "npm:^27.5.1" + jest-cli: "npm:^29.7.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -5529,7 +6041,7 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 10c0/c013d07e911e423612756bc42d376e578b8721d847db38d94344f9cdf8fdaa0241b0a5c2fe1aad7b7758d415e0b9517c1098312f0d03760f123958d5b6cf5597 + checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b languageName: node linkType: hard @@ -5577,46 +6089,6 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^16.6.0": - version: 16.7.0 - resolution: "jsdom@npm:16.7.0" - dependencies: - abab: "npm:^2.0.5" - acorn: "npm:^8.2.4" - acorn-globals: "npm:^6.0.0" - cssom: "npm:^0.4.4" - cssstyle: "npm:^2.3.0" - data-urls: "npm:^2.0.0" - decimal.js: "npm:^10.2.1" - domexception: "npm:^2.0.1" - escodegen: "npm:^2.0.0" - form-data: "npm:^3.0.0" - html-encoding-sniffer: "npm:^2.0.1" - http-proxy-agent: "npm:^4.0.1" - https-proxy-agent: "npm:^5.0.0" - is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.0" - parse5: "npm:6.0.1" - saxes: "npm:^5.0.1" - symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^4.0.0" - w3c-hr-time: "npm:^1.0.2" - w3c-xmlserializer: "npm:^2.0.0" - webidl-conversions: "npm:^6.1.0" - whatwg-encoding: "npm:^1.0.5" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.5.0" - ws: "npm:^7.4.6" - xml-name-validator: "npm:^3.0.0" - peerDependencies: - canvas: ^2.5.0 - peerDependenciesMeta: - canvas: - optional: true - checksum: 10c0/e9ba6ea5f5e0d18647ccedec16bc3c69c8c739732ffcb27c66ffd3cc3f876add291ca4f0b9c209ace939ce2aa3ba9e4d67b7f05317921a4d3eab02fe1cc164ef - languageName: node - linkType: hard - "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -5626,6 +6098,15 @@ __metadata: languageName: node linkType: hard +"jsesc@npm:^3.0.2": + version: 3.0.2 + resolution: "jsesc@npm:3.0.2" + bin: + jsesc: bin/jsesc + checksum: 10c0/ef22148f9e793180b14d8a145ee6f9f60f301abf443288117b4b6c53d0ecd58354898dc506ccbb553a5f7827965cd38bc5fb726575aae93c5e8915e2de8290e1 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -5675,15 +6156,6 @@ __metadata: languageName: node linkType: hard -"json5@npm:2.x, json5@npm:^2.2.3": - version: 2.2.3 - resolution: "json5@npm:2.2.3" - bin: - json5: lib/cli.js - checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c - languageName: node - linkType: hard - "json5@npm:^1.0.2": version: 1.0.2 resolution: "json5@npm:1.0.2" @@ -5695,6 +6167,15 @@ __metadata: languageName: node linkType: hard +"json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -5790,6 +6271,15 @@ __metadata: languageName: node linkType: hard +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: "npm:^2.0.5" + checksum: 10c0/ea4e509a5226ecfcc303ba6782cc269be8867d372b9bcbd625c88955df1987ea1a20da4643bf9270336415a398d33531ebf0d5f0d393b9283dc7c98bfcbd7b69 + languageName: node + linkType: hard + "lcov-parse@npm:^1.0.0": version: 1.0.0 resolution: "lcov-parse@npm:1.0.0" @@ -5975,7 +6465,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.x": +"lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10c0/c8713e51eccc650422716a14cece1809cfe34bc5ab5e242b7f8b4e2241c2483697b971a604252807689b9dd69bfe3a98852e19a5b89d506b000b4187a1285df8 @@ -6003,7 +6493,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.21, lodash@npm:^4.7.0": +"lodash@npm:^4.17.15, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c @@ -6095,7 +6585,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:1.x, make-error@npm:^1.3.5": +"make-error@npm:^1.3.5, make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f @@ -6241,8 +6731,17 @@ __metadata: version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1, minimatch@npm:^5.1.0": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 languageName: node linkType: hard @@ -6339,6 +6838,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 + languageName: node + linkType: hard + "mkdirp@npm:^0.5.4": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" @@ -6350,6 +6856,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf + languageName: node + linkType: hard + "mkdirp@npm:^3.0.1": version: 3.0.1 resolution: "mkdirp@npm:3.0.1" @@ -6393,7 +6908,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:2.1.3, ms@npm:^2.1.1": +"ms@npm:2.1.3, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 @@ -6458,6 +6973,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.19.0, nan@npm:^2.20.0": + version: 2.22.0 + resolution: "nan@npm:2.22.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/d5d31aefdb218deba308d44867c5f432b4d3aabeb57c70a2b236d62652e9fee7044e5d5afd380d9fef022fe7ebb2f2d6c85ca3cbcac5031aaca3592c844526bb + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -6565,6 +7089,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.18": + version: 2.0.18 + resolution: "node-releases@npm:2.0.18" + checksum: 10c0/786ac9db9d7226339e1dc84bbb42007cb054a346bd9257e6aa154d294f01bc6a6cddb1348fa099f079be6580acbb470e3c048effd5f719325abd0179e566fd27 + languageName: node + linkType: hard + "nodemon@npm:^2.0.15": version: 2.0.22 resolution: "nodemon@npm:2.0.22" @@ -6645,13 +7176,6 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.0": - version: 2.2.12 - resolution: "nwsapi@npm:2.2.12" - checksum: 10c0/95e9623d63df111405503df8c5d800e26f71675d319e2c9c70cddfa31e5ace1d3f8b6d98d354544fc156a1506d920ec291e303fab761e4f99296868e199a466e - languageName: node - linkType: hard - "oauth-sign@npm:~0.9.0": version: 0.9.0 resolution: "oauth-sign@npm:0.9.0" @@ -6762,7 +7286,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -6819,7 +7343,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -6907,13 +7431,6 @@ __metadata: languageName: node linkType: hard -"parse5@npm:6.0.1": - version: 6.0.1 - resolution: "parse5@npm:6.0.1" - checksum: 10c0/595821edc094ecbcfb9ddcb46a3e1fe3a718540f8320eff08b8cf6742a5114cce2d46d45f95c26191c11b184dcaf4e2960abcd9c5ed9eb9393ac9a37efcfdecb - languageName: node - linkType: hard - "parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -7012,6 +7529,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.1.0": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -7185,6 +7709,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f + languageName: node + linkType: hard + "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0" @@ -7199,6 +7734,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -7237,6 +7779,26 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/2f265dbad15897a43110a02dae55105c04d356ec4ed560723dcb9f0d34bc4fb2f13f79bb930e7561be10278e2314db5aca2527d5d3dcbbdee5e6b331d1571f6d + languageName: node + linkType: hard + +"properties-reader@npm:^2.3.0": + version: 2.3.0 + resolution: "properties-reader@npm:2.3.0" + dependencies: + mkdirp: "npm:^1.0.4" + checksum: 10c0/f665057e3a9076c643ba1198afcc71703eda227a59913252f7ff9467ece8d29c0cf8bf14bf1abcaef71570840c32a4e257e6c39b7550451bbff1a777efcf5667 + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -7261,6 +7823,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.2 + resolution: "pump@npm:3.0.2" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/5ad655cb2a7738b4bcf6406b24ad0970d680649d996b55ad20d1be8e0c02394034e4c45ff7cd105d87f1e9b96a0e3d06fd28e11fae8875da26e7f7a8e2c9726f + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -7268,6 +7840,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10c0/1abe217897bf74dcb3a0c9aba3555fe975023147b48db540aa2faf507aee91c03bf54f6aef0eb2bf59cc259a16d06b28eca37f0dc426d94f4692aeff02fb0e65 + languageName: node + linkType: hard + "qs@npm:6.11.0": version: 6.11.0 resolution: "qs@npm:6.11.0" @@ -7319,6 +7898,13 @@ __metadata: languageName: node linkType: hard +"queue-tick@npm:^1.0.1": + version: 1.0.1 + resolution: "queue-tick@npm:1.0.1" + checksum: 10c0/0db998e2c9b15215317dbcf801e9b23e6bcde4044e115155dae34f8e7454b9a783f737c9a725528d677b7a66c775eb7a955cf144fe0b87f62b575ce5bfd515a9 + languageName: node + linkType: hard + "random-bytes@npm:~1.0.0": version: 1.0.0 resolution: "random-bytes@npm:1.0.0" @@ -7372,6 +7958,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + "react-lifecycles-compat@npm:^3.0.4": version: 3.0.4 resolution: "react-lifecycles-compat@npm:3.0.4" @@ -7505,7 +8098,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.2.2": +"readable-stream@npm:^2.0.5, readable-stream@npm:^2.2.2": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -7520,7 +8113,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -7531,6 +8124,28 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.0.0": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/a2c80e0e53aabd91d7df0330929e32d0a73219f9477dbbb18472f6fdd6a11a699fc5d172a1beff98d50eae4f1496c950ffa85b7cc2c4c196963f289a5f39275d + languageName: node + linkType: hard + +"readdir-glob@npm:^1.1.2": + version: 1.1.3 + resolution: "readdir-glob@npm:1.1.3" + dependencies: + minimatch: "npm:^5.1.0" + checksum: 10c0/a37e0716726650845d761f1041387acd93aa91b28dd5381950733f994b6c349ddc1e21e266ec7cc1f9b92e205a7a972232f9b89d5424d07361c2c3753d5dbace + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -7677,10 +8292,10 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^1.1.0": - version: 1.1.1 - resolution: "resolve.exports@npm:1.1.1" - checksum: 10c0/902ac0c643d03385b2719f3aed8c289e9d4b2dd42c993de946de5b882bc18b74fad07d672d29f71a63c251be107f6d0d343e2390ca224c04ba9a8b8e35d1653a +"resolve.exports@npm:^2.0.0": + version: 2.0.2 + resolution: "resolve.exports@npm:2.0.2" + checksum: 10c0/cc4cffdc25447cf34730f388dca5021156ba9302a3bad3d7f168e790dc74b2827dff603f1bc6ad3d299bac269828dca96dd77e036dc9fba6a2a1807c47ab5c98 languageName: node linkType: hard @@ -7741,7 +8356,7 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.0, rimraf@npm:^3.0.1, rimraf@npm:^3.0.2": +"rimraf@npm:^3.0.1, rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" dependencies: @@ -7769,6 +8384,7 @@ __metadata: dependencies: "@onaio/gatekeeper": "npm:^1.0.0" "@onaio/session-reducer": "npm:^0.0.13" + "@testcontainers/redis": "npm:^10.11.0" "@types/adm-zip": "npm:^0.5.5" "@types/compression": "npm:^1.7.2" "@types/connect-redis": "npm:^0.0.18" @@ -7808,12 +8424,12 @@ __metadata: eslint-plugin-prettier: "npm:^4.0.0" eslint-plugin-sonarjs: "npm:^0.12.0" express: "npm:^4.17.3" - express-session: "npm:^1.17.2" + express-session: "npm:^1.18.1" helmet: "npm:^5.0.2" husky: "npm:^7.0.4" ioredis: "npm:^5.0.6" ioredis-mock: "npm:^8.2.2" - jest: "npm:^27.5.1" + jest: "npm:29.7.0" lint-staged: "npm:^12.3.4" mockdate: "npm:^3.0.5" morgan: "npm:^1.10.0" @@ -7835,7 +8451,8 @@ __metadata: seamless-immutable: "npm:^7.1.4" session-file-store: "npm:^1.5.0" supertest: "npm:^6.2.2" - ts-jest: "npm:^27.1.3" + testcontainers: "npm:^10.11.0" + ts-jest: "npm:^29.2.4" typescript: "npm:^4.5.5" winston: "npm:^3.6.0" languageName: unknown @@ -7910,15 +8527,6 @@ __metadata: languageName: node linkType: hard -"saxes@npm:^5.0.1": - version: 5.0.1 - resolution: "saxes@npm:5.0.1" - dependencies: - xmlchars: "npm:^2.2.0" - checksum: 10c0/b7476c41dbe1c3a89907d2546fecfba234de5e66743ef914cde2603f47b19bed09732ab51b528ad0f98b958369d8be72b6f5af5c9cfad69972a73d061f0b3952 - languageName: node - linkType: hard - "scheduler@npm:^0.20.2": version: 0.20.2 resolution: "scheduler@npm:0.20.2" @@ -7945,21 +8553,21 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4": - version: 7.6.3 - resolution: "semver@npm:7.6.3" +"semver@npm:^6.3.0, semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d languageName: node linkType: hard -"semver@npm:^6.3.0, semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" +"semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" bin: semver: bin/semver.js - checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf languageName: node linkType: hard @@ -8114,7 +8722,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 @@ -8220,30 +8828,23 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.6": - version: 0.5.21 - resolution: "source-map-support@npm:0.5.21" +"source-map-support@npm:0.5.13": + version: 0.5.13 + resolution: "source-map-support@npm:0.5.13" dependencies: buffer-from: "npm:^1.0.0" source-map: "npm:^0.6.0" - checksum: 10c0/9ee09942f415e0f721d6daad3917ec1516af746a8120bba7bb56278707a37f1eb8642bde456e98454b8a885023af81a16e646869975f06afc1a711fb90484e7d + checksum: 10c0/137539f8c453fa0f496ea42049ab5da4569f96781f6ac8e5bfda26937be9494f4e8891f523c5f98f0e85f71b35d74127a00c46f83f6a4f54672b58d53202565e languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 languageName: node linkType: hard -"source-map@npm:^0.7.3": - version: 0.7.4 - resolution: "source-map@npm:0.7.4" - checksum: 10c0/dc0cf3768fe23c345ea8760487f8c97ef6fca8a73c83cd7c9bf2fde8bc2c34adb9c0824d6feb14bc4f9e37fb522e18af621543f1289038a66ac7586da29aa7dc - languageName: node - linkType: hard - "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" @@ -8278,6 +8879,13 @@ __metadata: languageName: node linkType: hard +"split-ca@npm:^1.0.1": + version: 1.0.1 + resolution: "split-ca@npm:1.0.1" + checksum: 10c0/f339170b84c6b4706fcf4c60cc84acb36574c0447566bd713301a8d9b4feff7f4627efc8c334bec24944a3e2f35bc596bd58c673c9980d6bfe3137aae1116ba7 + languageName: node + linkType: hard + "split-on-first@npm:^1.0.0": version: 1.1.0 resolution: "split-on-first@npm:1.1.0" @@ -8299,6 +8907,33 @@ __metadata: languageName: node linkType: hard +"ssh-remote-port-forward@npm:^1.0.4": + version: 1.0.4 + resolution: "ssh-remote-port-forward@npm:1.0.4" + dependencies: + "@types/ssh2": "npm:^0.5.48" + ssh2: "npm:^1.4.0" + checksum: 10c0/33a441af12817577ea30d089b03c19f980d2fb2370933123a35026dc6be40f2dfce067e4dfc173e23d745464537ff647aa1bb7469be5571cc21f7cdb25181c09 + languageName: node + linkType: hard + +"ssh2@npm:^1.11.0, ssh2@npm:^1.4.0": + version: 1.16.0 + resolution: "ssh2@npm:1.16.0" + dependencies: + asn1: "npm:^0.2.6" + bcrypt-pbkdf: "npm:^1.0.2" + cpu-features: "npm:~0.0.10" + nan: "npm:^2.20.0" + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 10c0/d336a85d87501c64ba230b6c1a2901a9b0e376fe7f7a1640a7f8dbdafe674b2e1a5dc6236ffd1329969dc0cf03cd57759b28743075e61229a984065ee1d56bed + languageName: node + linkType: hard + "sshpk@npm:^1.7.0": version: 1.18.0 resolution: "sshpk@npm:1.18.0" @@ -8366,6 +9001,21 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.15.0, streamx@npm:^2.20.0": + version: 2.20.1 + resolution: "streamx@npm:2.20.1" + dependencies: + bare-events: "npm:^2.2.0" + fast-fifo: "npm:^1.3.2" + queue-tick: "npm:^1.0.1" + text-decoder: "npm:^1.1.0" + dependenciesMeta: + bare-events: + optional: true + checksum: 10c0/34ffa2ee9465d70e18c7e2ba70189720c166d150ab83eb7700304620fa23ff42a69cb37d712ea4b5fc6234d8e74346a88bb4baceb873c6b05e52ac420f8abb4d + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" @@ -8390,7 +9040,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -8458,7 +9108,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -8559,7 +9209,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0": +"supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: @@ -8584,16 +9234,6 @@ __metadata: languageName: node linkType: hard -"supports-hyperlinks@npm:^2.0.0": - version: 2.3.0 - resolution: "supports-hyperlinks@npm:2.3.0" - dependencies: - has-flag: "npm:^4.0.0" - supports-color: "npm:^7.0.0" - checksum: 10c0/4057f0d86afb056cd799602f72d575b8fdd79001c5894bcb691176f14e870a687e7981e50bc1484980e8b688c6d5bcd4931e1609816abb5a7dc1486b7babf6a1 - languageName: node - linkType: hard - "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -8601,10 +9241,56 @@ __metadata: languageName: node linkType: hard -"symbol-tree@npm:^3.2.4": - version: 3.2.4 - resolution: "symbol-tree@npm:3.2.4" - checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 +"tar-fs@npm:^3.0.6": + version: 3.0.6 + resolution: "tar-fs@npm:3.0.6" + dependencies: + bare-fs: "npm:^2.1.1" + bare-path: "npm:^2.1.0" + pump: "npm:^3.0.0" + tar-stream: "npm:^3.1.5" + dependenciesMeta: + bare-fs: + optional: true + bare-path: + optional: true + checksum: 10c0/207b7c0f193495668bd9dbad09a0108ce4ffcfec5bce2133f90988cdda5c81fad83c99f963d01e47b565196594f7a17dbd063ae55b97b36268fcc843975278ee + languageName: node + linkType: hard + +"tar-fs@npm:~2.0.1": + version: 2.0.1 + resolution: "tar-fs@npm:2.0.1" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.0.0" + checksum: 10c0/0128e888b61c7c4e8e7997d66ceccc3c79d73c01e87cfcc3d9f6b8555b0c88b8d67d91ff167f00b067f726dde497b2d1fb2bba0cfcb3ccb95ae413cb86c715bc + languageName: node + linkType: hard + +"tar-stream@npm:^2.0.0": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 + languageName: node + linkType: hard + +"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": + version: 3.1.7 + resolution: "tar-stream@npm:3.1.7" + dependencies: + b4a: "npm:^1.6.4" + fast-fifo: "npm:^1.2.0" + streamx: "npm:^2.15.0" + checksum: 10c0/a09199d21f8714bd729993ac49b6c8efcb808b544b89f23378ad6ffff6d1cb540878614ba9d4cfec11a64ef39e1a6f009a5398371491eb1fda606ffc7f70f718 languageName: node linkType: hard @@ -8622,16 +9308,6 @@ __metadata: languageName: node linkType: hard -"terminal-link@npm:^2.0.0": - version: 2.1.1 - resolution: "terminal-link@npm:2.1.1" - dependencies: - ansi-escapes: "npm:^4.2.1" - supports-hyperlinks: "npm:^2.0.0" - checksum: 10c0/947458a5cd5408d2ffcdb14aee50bec8fb5022ae683b896b2f08ed6db7b2e7d42780d5c8b51e930e9c322bd7c7a517f4fa7c76983d0873c83245885ac5ee13e3 - languageName: node - linkType: hard - "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -8643,6 +9319,36 @@ __metadata: languageName: node linkType: hard +"testcontainers@npm:^10.11.0, testcontainers@npm:^10.13.2": + version: 10.13.2 + resolution: "testcontainers@npm:10.13.2" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + "@types/dockerode": "npm:^3.3.29" + archiver: "npm:^7.0.1" + async-lock: "npm:^1.4.1" + byline: "npm:^5.0.0" + debug: "npm:^4.3.5" + docker-compose: "npm:^0.24.8" + dockerode: "npm:^3.3.5" + get-port: "npm:^5.1.1" + proper-lockfile: "npm:^4.1.2" + properties-reader: "npm:^2.3.0" + ssh-remote-port-forward: "npm:^1.0.4" + tar-fs: "npm:^3.0.6" + tmp: "npm:^0.2.3" + undici: "npm:^5.28.4" + checksum: 10c0/da08bb1e180871d5481751131d1802135fc3aaa6e492b4159475b4dc8c3ca49c0bf4cd9b51e56cc21d698089a6be14536d1ddc75a6ac13ab8bed8cce3129d623 + languageName: node + linkType: hard + +"text-decoder@npm:^1.1.0": + version: 1.2.1 + resolution: "text-decoder@npm:1.2.1" + checksum: 10c0/deea9e3f4bde3b8990439e59cd52b2e917a416e29fbaf607052c89117c7148f1831562c099e9dd49abea0839cffdeb75a3c8f1f137f1686afd2808322f8e3f00 + languageName: node + linkType: hard + "text-hex@npm:1.0.x": version: 1.0.0 resolution: "text-hex@npm:1.0.0" @@ -8657,13 +9363,6 @@ __metadata: languageName: node linkType: hard -"throat@npm:^6.0.1": - version: 6.0.2 - resolution: "throat@npm:6.0.2" - checksum: 10c0/45caf1ce86a895f71fcb9bd3de67e1df6f73a519e780765dd0cf63ca8363de08ad207cfb714bc650ee9ddeef89971517b5f3a64087fcffce2bda034697af7c18 - languageName: node - linkType: hard - "through@npm:^2.3.8": version: 2.3.8 resolution: "through@npm:2.3.8" @@ -8701,6 +9400,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.3": + version: 0.2.3 + resolution: "tmp@npm:0.2.3" + checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -8740,7 +9446,7 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.0.0, tough-cookie@npm:^4.1.3": +"tough-cookie@npm:^4.1.3": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4" dependencies: @@ -8762,15 +9468,6 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^2.1.0": - version: 2.1.0 - resolution: "tr46@npm:2.1.0" - dependencies: - punycode: "npm:^2.1.1" - checksum: 10c0/397f5c39d97c5fe29fa9bab73b03853be18ad2738b2c66ee5ce84ecb36b091bdaec493f9b3cee711d45f7678f342452600843264cc8242b591c8dc983146a6c4 - languageName: node - linkType: hard - "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -8792,28 +9489,32 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^27.1.3": - version: 27.1.5 - resolution: "ts-jest@npm:27.1.5" +"ts-jest@npm:^29.2.4": + version: 29.2.5 + resolution: "ts-jest@npm:29.2.5" dependencies: - bs-logger: "npm:0.x" - fast-json-stable-stringify: "npm:2.x" - jest-util: "npm:^27.0.0" - json5: "npm:2.x" - lodash.memoize: "npm:4.x" - make-error: "npm:1.x" - semver: "npm:7.x" - yargs-parser: "npm:20.x" + bs-logger: "npm:^0.2.6" + ejs: "npm:^3.1.10" + fast-json-stable-stringify: "npm:^2.1.0" + jest-util: "npm:^29.0.0" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.6.3" + yargs-parser: "npm:^21.1.1" peerDependencies: "@babel/core": ">=7.0.0-beta.0 <8" - "@types/jest": ^27.0.0 - babel-jest: ">=27.0.0 <28" - jest: ^27.0.0 - typescript: ">=3.8 <5.0" + "@jest/transform": ^29.0.0 + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3 <6" peerDependenciesMeta: "@babel/core": optional: true - "@types/jest": + "@jest/transform": + optional: true + "@jest/types": optional: true babel-jest: optional: true @@ -8821,7 +9522,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10c0/af11586658a0766dcc82ba540448334f8370eb71b22f5d6749b1dc0a203b30e766ab3c02e4c7ed4b1f4c862613c2bb0cbc275d28922bed7d7a06e3b3af73fba1 + checksum: 10c0/acb62d168faec073e64b20873b583974ba8acecdb94681164eb346cef82ade8fb481c5b979363e01a97ce4dd1e793baf64d9efd90720bc941ad7fc1c3d6f3f68 languageName: node linkType: hard @@ -9041,6 +9742,15 @@ __metadata: languageName: node linkType: hard +"undici@npm:^5.28.4": + version: 5.28.4 + resolution: "undici@npm:5.28.4" + dependencies: + "@fastify/busboy": "npm:^2.0.0" + checksum: 10c0/08d0f2596553aa0a54ca6e8e9c7f45aef7d042c60918564e3a142d449eda165a80196f6ef19ea2ef2e6446959e293095d8e40af1236f0d67223b06afac5ecad7 + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" @@ -9094,6 +9804,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.1.1": + version: 1.1.1 + resolution: "update-browserslist-db@npm:1.1.1" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.0" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/536a2979adda2b4be81b07e311bd2f3ad5e978690987956bc5f514130ad50cac87cd22c710b686d79731e00fbee8ef43efe5fcd72baa241045209195d43dcc80 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -9145,14 +9869,14 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^8.1.0": - version: 8.1.1 - resolution: "v8-to-istanbul@npm:8.1.1" +"v8-to-istanbul@npm:^9.0.1": + version: 9.3.0 + resolution: "v8-to-istanbul@npm:9.3.0" dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" "@types/istanbul-lib-coverage": "npm:^2.0.1" - convert-source-map: "npm:^1.6.0" - source-map: "npm:^0.7.3" - checksum: 10c0/c3c99c4aa1ffffb098cc85c0c13c21871e6cbb9a83537d4e0650aa61589c347b2add787ceac68b8ea7fa1b7f446e9059d8e374cd7e7ab13b170a6caf8ad29c30 + convert-source-map: "npm:^2.0.0" + checksum: 10c0/968bcf1c7c88c04df1ffb463c179558a2ec17aa49e49376120504958239d9e9dad5281aa05f2a78542b8557f2be0b0b4c325710262f3b838b40d703d5ed30c23 languageName: node linkType: hard @@ -9191,25 +9915,7 @@ __metadata: languageName: node linkType: hard -"w3c-hr-time@npm:^1.0.2": - version: 1.0.2 - resolution: "w3c-hr-time@npm:1.0.2" - dependencies: - browser-process-hrtime: "npm:^1.0.0" - checksum: 10c0/7795b61fb51ce222414891eef8e6cb13240b62f64351b4474f99c84de2bc37d37dd0efa193f37391e9737097b881a111d1e003e3d7a9583693f8d5a858b02627 - languageName: node - linkType: hard - -"w3c-xmlserializer@npm:^2.0.0": - version: 2.0.0 - resolution: "w3c-xmlserializer@npm:2.0.0" - dependencies: - xml-name-validator: "npm:^3.0.0" - checksum: 10c0/92b8af34766f5bb8f37c505bc459ee1791b30af778d3a86551f7dd3b1716f79cb98c71d65d03f2bf6eba6b09861868eaf2be7e233b9202b26a9df7595f2bd290 - languageName: node - linkType: hard - -"walker@npm:^1.0.7": +"walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: @@ -9225,36 +9931,6 @@ __metadata: languageName: node linkType: hard -"webidl-conversions@npm:^5.0.0": - version: 5.0.0 - resolution: "webidl-conversions@npm:5.0.0" - checksum: 10c0/bf31df332ed11e1114bfcae7712d9ab2c37e7faa60ba32d8fdbee785937c0b012eee235c19d2b5d84f5072db84a160e8d08dd382da7f850feec26a4f46add8ff - languageName: node - linkType: hard - -"webidl-conversions@npm:^6.1.0": - version: 6.1.0 - resolution: "webidl-conversions@npm:6.1.0" - checksum: 10c0/66ad3b9073cd1e0e173444d8c636673b016e25b5856694429072cc966229adb734a8d410188e031effadcfb837936d79bc9e87c48f4d5925a90d42dec97f6590 - languageName: node - linkType: hard - -"whatwg-encoding@npm:^1.0.5": - version: 1.0.5 - resolution: "whatwg-encoding@npm:1.0.5" - dependencies: - iconv-lite: "npm:0.4.24" - checksum: 10c0/79d9f276234fd06bb27de4c1f9137a0471bfa578efaec0474ab46b6d64bf30bb14492e6f88eff0e6794bdd6fa48b44f4d7a2e9c41424a837a63bba9626e35c62 - languageName: node - linkType: hard - -"whatwg-mimetype@npm:^2.3.0": - version: 2.3.0 - resolution: "whatwg-mimetype@npm:2.3.0" - checksum: 10c0/81c5eaf660b1d1c27575406bcfdf58557b599e302211e13e3c8209020bbac903e73c17f9990f887232b39ce570cc8638331b0c3ff0842ba224a5c2925e830b06 - languageName: node - linkType: hard - "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -9265,17 +9941,6 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^8.0.0, whatwg-url@npm:^8.5.0": - version: 8.7.0 - resolution: "whatwg-url@npm:8.7.0" - dependencies: - lodash: "npm:^4.7.0" - tr46: "npm:^2.1.0" - webidl-conversions: "npm:^6.1.0" - checksum: 10c0/de0bc94387dba586b278e701cf5a1c1f5002725d22b8564dbca2cab1966ef24b839018e57ae2423fb514d8a2dd3aa3bf97323e2f89b55cd89e79141e432e9df1 - languageName: node - linkType: hard - "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" @@ -9412,7 +10077,7 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:3.0.3, write-file-atomic@npm:^3.0.0": +"write-file-atomic@npm:3.0.3": version: 3.0.3 resolution: "write-file-atomic@npm:3.0.3" dependencies: @@ -9424,32 +10089,13 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.4.6": - version: 7.5.10 - resolution: "ws@npm:7.5.10" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/bd7d5f4aaf04fae7960c23dcb6c6375d525e00f795dd20b9385902bd008c40a94d3db3ce97d878acc7573df852056ca546328b27b39f47609f80fb22a0a9b61d - languageName: node - linkType: hard - -"xml-name-validator@npm:^3.0.0": - version: 3.0.0 - resolution: "xml-name-validator@npm:3.0.0" - checksum: 10c0/da310f6a7a52f8eb0fce3d04ffa1f97387ca68f47e8620ae3a259909c4e832f7003313b918e53840a6bf57fb38d5ae3c5f79f31f911b2818a7439f7898f8fbf1 - languageName: node - linkType: hard - -"xmlchars@npm:^2.2.0": - version: 2.2.0 - resolution: "xmlchars@npm:2.2.0" - checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 10c0/a2c282c95ef5d8e1c27b335ae897b5eca00e85590d92a3fd69a437919b7b93ff36a69ea04145da55829d2164e724bc62202cdb5f4b208b425aba0807889375c7 languageName: node linkType: hard @@ -9495,25 +10141,34 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:20.x, yargs-parser@npm:^20.2.2": - version: 20.2.9 - resolution: "yargs-parser@npm:20.2.9" - checksum: 10c0/0685a8e58bbfb57fab6aefe03c6da904a59769bd803a722bb098bd5b0f29d274a1357762c7258fb487512811b8063fb5d2824a3415a0a4540598335b3b086c72 +"yaml@npm:^2.2.2": + version: 2.6.0 + resolution: "yaml@npm:2.6.0" + bin: + yaml: bin.mjs + checksum: 10c0/9e74cdb91cc35512a1c41f5ce509b0e93cc1d00eff0901e4ba831ee75a71ddf0845702adcd6f4ee6c811319eb9b59653248462ab94fa021ab855543a75396ceb languageName: node linkType: hard -"yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.3.1": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" dependencies: - cliui: "npm:^7.0.2" + cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" get-caller-file: "npm:^2.0.5" require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.0" + string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" - yargs-parser: "npm:^20.2.2" - checksum: 10c0/b1dbfefa679848442454b60053a6c95d62f2d2e21dd28def92b647587f415969173c6e99a0f3bab4f1b67ee8283bf735ebe3544013f09491186ba9e8a9a2b651 + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 languageName: node linkType: hard @@ -9523,3 +10178,14 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"zip-stream@npm:^6.0.1": + version: 6.0.1 + resolution: "zip-stream@npm:6.0.1" + dependencies: + archiver-utils: "npm:^5.0.0" + compress-commons: "npm:^6.0.2" + readable-stream: "npm:^4.0.0" + checksum: 10c0/50f2fb30327fb9d09879abf7ae2493705313adf403e794b030151aaae00009162419d60d0519e807673ec04d442e140c8879ca14314df0a0192de3b233e8f28b + languageName: node + linkType: hard