diff --git a/.gitignore b/.gitignore index 99e13355..bfcfa809 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ config.json .vscode/ .nox/ .python-version +.coverage diff --git a/README.md b/README.md index 0ddc0746..f60871e9 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,56 @@ -# datagateway-api -ICAT API to interface with the Data Gateway - -## Contents -- [datagateway-api](#datagateway-api) - - [Contents](#contents) - - [Creating Dev Environment and API Setup](#creating-dev-environment-and-api-setup) - - [Running DataGateway API](#running-datagateway-api) - - [Project structure](#project-structure) - - [Main](#main) - - [Endpoints](#endpoints) - - [Mapped classes](#mapped-classes) - - [Database Generator](#database-generator) - - [Class Diagrams](#class-diagrams-for-this-module) - - [Querying and filtering](#querying-and-filtering) - - [Swagger Generation](#generating-the-swagger-spec-openapiyaml) - - [Authentication](#authentication) +# DataGateway API +This is a Flask-based API that fetches data from an ICAT instance, to interface with +[DataGateway](https://github.com/ral-facilities/datagateway). This API uses two ways +for data collection/manipulation, using a Python-based ICAT API wrapper or using +sqlalchemy to communicate directly with ICAT's database. + + + + +# Contents +- [Creating Dev Environment and API Setup](#creating-dev-environment-and-api-setup) + - [Python Version Management (pyenv)](#python-version-management-(pyenv)) + - [API Dependency Management (Poetry)](#api-dependency-management-(poetry)) + - [Automated Testing & Other Development Helpers (Nox)](#automated-testing-&-other-development-helpers-(nox)) + - [Automated Checks during Git Commit (Pre Commit)](#automated-checks-during-git-commit-(pre-commit)) + - [Summary](#summary) +- [Running DataGateway API](#running-datagateway-api) + - [API Startup](#api-startup) + - [Authentication](#authentication) + - [Swagger Interface](#swagger-interface) +- [Running Tests](#running-tests) +- [Project Structure](#project-structure) + - [Main](#main) + - [Endpoints](#endpoints) + - [Logging](#logging) + - [Date Handler](#date-handler) + - [Exceptions & Flask Error Handling](#exceptions-&-flask-error-handling) + - [Filtering](#filtering) + - [Backends](#backends) + - [Abstract Backend Class](#abstract-backend-class) + - [Creating a Backend](#creating-a-backend) + - [Database Backend](#database-backend) + - [Mapped Classes](#mapped-classes) + - [Python ICAT Backend](#python-icat-backend) + - [ICATQuery](#icatquery) + - [Generating the OpenAPI Specification](#generating-the-openapi-specification) +- [Utilities](#utilities) - [Database Generator](#database-generator) - - [Running Tests](#running-tests) + - [Postman Collection](#postman-collection) +- [Updating README](#updating-readme) -## Creating Dev Environment and API Setup + + +# Creating Dev Environment and API Setup The recommended development environment for this API has taken lots of inspiration from the [Hypermodern Python](https://cjolowicz.github.io/posts/hypermodern-python-01-setup/) guide found online. It is assumed the commands shown in this part of the README are executed in the root directory of this repo once it has been cloned to your local machine. -### pyenv (Python Version Management) + +## Python Version Management (pyenv) To start, install [pyenv](https://github.com/pyenv/pyenv). There is a Windows version of this tool ([pyenv-win](https://github.com/pyenv-win/pyenv-win)), however this is currently untested on this repo. This is used to manage the various versions of Python @@ -85,7 +109,8 @@ currently listed in `.gitignore`): pyenv local 3.6.8 3.7.7 3.8.2 ``` -### Poetry (API Dependency Management) + +## API Dependency Management (Poetry) To maintain records of the API's dependencies, [Poetry](https://github.com/python-poetry/poetry) is used. To install, use the following command: @@ -120,13 +145,16 @@ intricacies of this command: poetry add [PACKAGE-NAME] ``` -### Nox (Automated Testing & Other Code Changes) + +## Automated Testing & Other Development Helpers (Nox) When developing new features for the API, there are a number of Nox sessions that can be used to lint/format/test the code in the included `noxfile.py`. To install Nox, use Pip as shown below. Nox is not listed as a Poetry dependency because this has the potential to cause issues if Nox was executed inside Poetry (see [here](https://medium.com/@cjolowicz/nox-is-a-part-of-your-global-developer-environment-like-poetry-pre-commit-pyenv-or-pipx-1cdeba9198bd) -for more detailed reasoning). If you do choose to install these packages within a +for more detailed reasoning). When using the `--user` option, ensure your user's Python +installation is added to the system `PATH` variable, remembering to reboot your system +if you need to change the `PATH`. If you do choose to install these packages within a virtual environment, you do not need the `--user` option: ```bash @@ -154,23 +182,20 @@ Currently, the following Nox sessions have been created: - `safety` - this uses [safety](https://github.com/pyupio/safety) to check the dependencies (pulled directly from Poetry) for any known vulnerabilities. This session gives the output in a full ASCII style report. +- `tests` - this uses [pytest](https://docs.pytest.org/en/stable/) to execute the + automated tests in `test/`, tests for the database and ICAT backends, and non-backend + specific tests. More details about the tests themselves [here](#running-tests). Each Nox session builds an environment using the repo's dependencies (defined using Poetry) using `install_with_constraints()`. This stores the dependencies in a `requirements.txt`-like format temporarily during this process, using the OS' default -temporary location. This could result in permissions issues (this has been seen by a -colleague on Windows), so adding the `--tmpdir [DIRECTORY PATH]` allows the user to -define where this file should be stored. Due to Nox session being initiated in the -command line, this argument needs to be a positional argument (denoted by the `--` in -the Nox command). This argument is optional, but **must** be the final argument avoid -interference with Nox's argument parsing. An example: - -```bash -nox -s lint -- util datagateway_api --tmpdir /root -``` +temporary location. These files are manually deleted in `noxfile.py` (as opposed to +being automatically removed by Python) to minimise any potential permission-related +issues as documented +[here](https://github.com/bravoserver/bravo/issues/111#issuecomment-826990). -### Pre Commit (Automated Checks during Git Commit) +## Automated Checks during Git Commit (Pre Commit) To make use of Git's ability to run custom hooks, [pre-commit](https://pre-commit.com/) is used. Like Nox, Pip is used to install this tool: @@ -194,8 +219,8 @@ command: pre-commit run --all-files ``` -### Summary +## Summary As a summary, these are the steps needed to create a dev environment for this repo compressed into a single code block: @@ -246,54 +271,148 @@ pre-commit install ``` -## Running DataGateway API -Depending on the backend you want to use (either `db` or `python_icat`) the connection -URL for the backend needs to be set. These are set in `config.json` (an example file is -provided in the base directory of this repository). Copy `config.json.example` to -`config.json` and set the values as needed. -Ideally, the API would be run with: -`poetry run python -m src.main` -However it can be run with the flask run command as shown below: +# Running DataGateway API +Depending on the backend you want to use (either `db` or `python_icat`, more details +about backends [here](#backends)) the connection URL for the backend needs to be set. +These are set in `config.json` (an example file is provided in the base directory of +this repository). While both `DB_URL` and `ICAT_URL` should have values assigned to them +(for best practice), `DB_URL` will only be used for the database backend, and `ICAT_URL` + will only be used for the Python ICAT backend. Copy `config.json.example` to +`config.json` and set the values as needed. If you need to create an instance of ICAT, +there are a number of markdown-formatted tutorials that can be found on the +[icat.manual](https://github.com/icatproject/icat.manual/tree/master/tutorials) +repository. + +By default, the API will run on `http://localhost:5000` and all requests are made here +e.g. `http://localhost:5000/sessions`. + + +## API Startup +Ideally, the API would be run using the following command, the alternative (detailed +below) should only be used for development purposes. -**Warning: the host, port and debug config options will not be respected when the API is +```bash +poetry run python -m datagateway_api.src.main +``` + +However, it can also be run with the `flask run` command (installed with Flask). To use +`flask run`, the enviroment variable `FLASK_APP` should be set to +`datagateway_api/src/main.py`. Once this is set, the API can be run with `flask run` +while inside the root directory of the project. This shouldn't be used in production, as +detailed in Flask's documentation, this method of running the API is only +["provided for convenience"](https://flask.palletsprojects.com/en/1.1.x/cli/#run-the-development-server). + +**WARNING: the host, port and debug config options will not be respected when the API is run this way** -To use `flask run`, the enviroment variable `FLASK_APP` should be set to `src/main.py`. -Once this is set the API can be run with `flask run` while inside the root directory of -the project. The `flask run` command gets installed with flask. +Examples: -Examples shown: -Unix +Unix: ```bash -$ export FLASK_APP=src/main.py +$ export FLASK_APP=datagateway_api/src/main.py $ flask run ``` -CMD + +CMD: ```CMD -> set FLASK_APP=src/main.py +> set FLASK_APP=datagateway_api/src/main.py > flask run ``` -PowerShell + +PowerShell: ```powershell -> $env:FLASK_APP = "src/main.py" +> $env:FLASK_APP = "datagateway_api/src/main.py" > flask run ``` -More information can be found [here](http://flask.pocoo.org/docs/1.0/cli/). -By default the api will run on `http://localhost:5000` and all requests are made here -e.g. `http://localhost:5000/sessions` +## Authentication +Each request requires a valid session ID to be provided in the Authorization header. +This header should take the form of `{"Authorization":"Bearer "}` A session +ID can be obtained by sending a POST request to `/sessions`. All endpoint methods that +require a session id are decorated with `@requires_session_id`. + + +## Swagger Interface +If you go to the API's base path in your browser (`http://localhost:5000` by default), a +representation of the API will be shown using +[Swagger UI](https://swagger.io/tools/swagger-ui/). This uses an OpenAPI specfication to +visualise and allow users to easily interact with the API without building their own +requests. It's great for gaining an understanding in what endpoints are available and +what inputs the requests can receive, all from an interactive interface. -## Project structure -The project consists of 3 main packages: common, src and test. common contains modules -shared across test and src such as the database mapping classes. src contains the api -resources and their http method definitions, and test contains tests for each endpoint. +This specification is built with the Database Backend in mind (attribute names on +example outputs are capitalised for example), however the Swagger interface can also be +used with the Python ICAT Backend. More details on how the API's OpenAPI specification +is built can be found [here](#generating-the-openapi-specification). -This is illustrated below. + +# Running Tests +To run the tests use `nox -s tests`. The repository contains a variety of tests, to test +the functionality of the API works as intended. The tests are split into 3 main +sections: non-backend specific (testing features such as the date handler), ICAT backend +tests (containing tests for backend specific components, including tests for the +different types of endpoints) and Database Backend tests (like the ICAT backend tests, +but covering only the most used aspects of the API). + +The configuration file (`config.json`) contains two options that will be used during the +testing of the API. Set `test_user_credentials` and `test_mechanism` appropriately for +your test environment, using `config.json.example` as a reference. The tests require a +connection to an instance of ICAT, so set the rest of the config as needed. + +By default, this will execute the repo's tests in +Python 3.6, 3.7 and 3.8. For most cases, running the tests in a single Python version +will be sufficient: + +```bash +nox -p 3.6 -s tests +``` + +This repository also utilises [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/) +to check how much of the codebase is covered by the tests in `test/`: + +```bash +nox -p 3.6 -s tests -- --cov-report term --cov=./datagateway_api +``` + +With `pytest`, you can output the duration for each test, useful for showing the slower +tests in the collection (sortest from slowest to fastest). The test duration is split +into setup, call and teardown to more easily understand where the tests are being slowed +down: + +```bash +nox -p 3.6 -s tests -- --durations=0 +``` + +To test a specific test class (or even a specific test function), use a double colon to +denote a each level down beyond the filename: + +```bash +# Test a specific file +nox -p 3.6 -s tests -- test/icat/test_query.py + +# Test a specific test class +nox -p 3.6 -s tests -- test/icat/test_query.py::TestICATQuery + +# Test a specific test function +nox -p 3.6 -s tests -- test/icat/test_query.py::TestICATQuery::test_valid_query_exeuction +``` + + + + +# Project Structure +The project consists of 3 main packages: `datagateway_api.common`, +`datagateway_api.src`, and `test`. `datagateway_api.common` contains modules for the +Database and Python ICAT Backends as well as code to deal with query filters. +`datagateway_api.src` contains the API resources and their HTTP method definitions (e.g. +GET, POST). `test` contains automated tests written using Pytest. A directory tree is +illustrated below: + ````` . ├── .flake8 @@ -324,7 +443,8 @@ This is illustrated below. │ │ │ ├── filters.py │ │ │ ├── helpers.py │ │ │ └── query.py -│ │ └── logger_setup.py +│ │ ├── logger_setup.py +│ │ └── query_filter_factory.py │ └── src │ ├── main.py │ ├── resources @@ -344,48 +464,226 @@ This is illustrated below. ├── postman_collection_icat.json ├── pyproject.toml ├── test +│ ├── conftest.py +│ ├── db +│ │ ├── conftest.py +│ │ ├── endpoints +│ │ │ ├── test_count_with_filters_db.py +│ │ │ ├── test_findone_db.py +│ │ │ ├── test_get_by_id_db.py +│ │ │ ├── test_get_with_filters.py +│ │ │ └── test_table_endpoints_db.py +│ │ ├── test_entity_helper.py +│ │ ├── test_query_filter_factory.py +│ │ └── test_requires_session_id.py +│ ├── icat +│ │ ├── conftest.py +│ │ ├── endpoints +│ │ │ ├── test_count_with_filters_icat.py +│ │ │ ├── test_create_icat.py +│ │ │ ├── test_delete_by_id_icat.py +│ │ │ ├── test_findone_icat.py +│ │ │ ├── test_get_by_id_icat.py +│ │ │ ├── test_get_with_filters_icat.py +│ │ │ ├── test_table_endpoints_icat.py +│ │ │ ├── test_update_by_id_icat.py +│ │ │ └── test_update_multiple_icat.py +│ │ ├── filters +│ │ │ ├── test_distinct_filter.py +│ │ │ ├── test_include_filter.py +│ │ │ ├── test_limit_filter.py +│ │ │ ├── test_order_filter.py +│ │ │ ├── test_skip_filter.py +│ │ │ └── test_where_filter.py +│ │ ├── test_filter_order_handler.py +│ │ ├── test_query.py +│ │ └── test_session_handling.py +│ ├── test_backends.py │ ├── test_base.py -│ ├── test_database_helpers.py -│ ├── test_entityHelper.py -│ └── test_helpers.py +│ ├── test_config.py +│ ├── test_date_handler.py +│ ├── test_endpoint_rules.py +│ ├── test_get_filters_from_query.py +│ ├── test_get_session_id_from_auth_header.py +│ ├── test_is_valid_json.py +│ └── test_queries_records.py └── util └── icat_db_generator.py - ````` - -The directory tree can be generated using the following command: - - `git ls-tree -r --name-only HEAD | grep -v __init__.py | tree --fromfile` +````` -#### Main -`main.py` is where the flask_restful api is set up. This is where each endpoint resource +## Main +`main.py` is where the flask_restful API is set up. This is where each endpoint resource class is generated and mapped to an endpoint. Example: ```python -api.add_resource(get_endpoint(entity_name, endpoints[entity_name]), f"/{entity_name.lower()}") +api.add_resource(get_endpoint_resource, f"/{entity_name.lower()}") ``` -#### Endpoints -The logic for each endpoint are within `/src/resources`. They are split into entities, -non_entities and table_endpoints. The entities package contains `entities_map` which -maps entity names to their sqlalchemy model. The `entity_endpoint` module contains the -function that is used to generate endpoints at start up. `table_endpoints` contains the -endpoint classes that are table specific. Finally, non_entities contains the session -endpoint. +## Endpoints +The logic for each endpoint is within `/src/resources`. They are split into entities, +non_entities and table_endpoints. + +The entities package contains `entity_map` which +maps entity names to their field name used in backend-specific code. The Database +Backend uses this for its mapped classes (explained below) and the Python ICAT Backend +uses this for interacting with ICAT objects within Python ICAT. In most instances, the +dictionary found in `entity_map.py` is simply mapping the plural entity name (used to +build the entity endpoints) to the singular version. The `entity_endpoint` module +contains the function that is used to generate endpoints at start up. `table_endpoints` +contains the endpoint classes that are table specific (currently these are the ISIS +specific endpoints required for their use cases). Finally, `non_entities` contains the +session endpoint for session handling. + + +## Logging +Logging configuration can be found in `datagateway_api.common.logger_setup.py`. This +contains a typical dictionary-based config for the standard Python `logging` library +that rotates files after they become 5MB in size. + +The default logging location is in the root directory of this repo. This location (and +filename) can be changed by editing the `log_location` value in `config.json`. The log +level (set to `WARN` by default) can also be changed using the appropriate value in that +file. + + +## Date Handler +This is a class containing static methods to deal with dates within the API. The date +handler can be used to convert dates between string and datetime objects (using a format +agreed in `datagateway_api.common.constants`) and uses a parser from `dateutil` to +detect if an input contains a date. This is useful for determining if a JSON value given +in a request body is a date, at which point it can be converted to a datetime object, +ready for storing in ICAT. The handler is currently only used in the Python ICAT +Backend, however this is non-backend specific class. + + +## Exceptions & Flask Error Handling +Exceptions custom to DataGateway API are defined in `datagateway_api.common.exceptions`. +Each exception has a status code and a default message (which can be changed when +raising the exception in code). None of them are backend specific, however some are only +used in a single backend because their meaning becomes irrelevant anywhere else. + +When the API is setup in `main.py`, a custom API object is created (inheriting +flask_restful's `Api` object) so `handle_error()` can be overridden. A previous +iteration of the API registered a error handler with the `Api` object, however this +meant DataGateway API's custom error handling only worked as intended in debug mode (as +detailed in a +[GitHub issue](https://github.com/ral-facilities/datagateway-api/issues/147)). This +solution prevents any exception returning a 500 status code (no matter the defined +status code in `exceptions.py`) in production mode. This is explained in a +[Stack Overflow answer](https://stackoverflow.com/a/43534068). + + +## Filtering +Filters available for use in the API are defined in `datagateway_api.common.filters`. +These filters are all based from `QueryFilter`, an asbtract class to define any filter +for the API. Precedence is used to prioritise in which order filters should be applied, +but is only needed for the Database Backend. + +Filtering logic is located in `datagateway_api.common.helpers`. +`get_filters_from_query_string()` uses the request query parameters to form filters to +be used within the API. A `QueryFilterFactory` is used to build filters for the correct +backend and the static method within this class is called in +`get_filters_from_query_string()`. + + +## Backends +As described at the top of this file, there are currently two ways that the API +creates/fetches/updates/deletes data from ICAT. The intention is each backend allows a +different method to communicate with ICAT, but results in a very similarly behaving +DataGateway API. + + +### Abstract Backend Class +The abstract class can be found in `datagateway_api.common.backend` and contains all the +abstract methods that should be found in a class which implements `Backend`. The typical +architecture across both backends is that the implemented functions call a helper +function to process the request and the result of that is returned to the user. + +Each backend module contains the following files which offer similar functionality, +implemented in their own ways: +- `backend.py` - Implemented version of `datagateway_api.common.backend` +- `filters.py` - Inherited versions of each filter defined in + `datagateway_api.common.filters` +- `helpers.py` - Helper functions that are called in `backend.py` + + +### Creating a Backend +A function inside `datagateway_api.common.backends` creates an instance of a backend +using input to that function to decide which backend to create. This function is called +in `main.py` which uses the backend type set in `config.json`, or a config value in the +Flask app if it's set (this config option is only used in the tests however). The +backend object is then parsed into the endpoint classes so the correct backend can be +used. + + +## Database Backend +The Database Backend uses [SQLAlchemy](https://www.sqlalchemy.org/) to interface +directly with the database for an instance of ICAT. This backend favours speed over +thoroughness, allowing no control over which users can access a particular piece of +data. + + +### Mapped Classes +The classes mapped from the database (as described [above](#endpoints)) are stored in +`/common/database/models.py`. Each model was automatically generated using sqlacodegen. +A class `EntityHelper` is defined so that each model may inherit two methods `to_dict()` +and `update_from_dict(dictionary)`, both used for returning entities and updating them, +in a form easily converted to JSON. + + +## Python ICAT Backend +Sometimes referred to as the ICAT Backend, this uses +[python-icat](https://python-icat.readthedocs.io/en/stable/) to interact with ICAT data. +The Python-based API wrapper allows ICAT Server to be accessed using the SOAP interface. +Python ICAT allows control over which users can access a particular piece of data, with +the API supporting multiple authentication mechanisms. Meta attributes such as `modId` +are dealt by Python ICAT, rather than the API. + + +### ICATQuery +The ICATQuery classed is in `datagateway_api.common.icat.query`. This class stores a +query created with Python ICAT +[documentation for the query](https://python-icat.readthedocs.io/en/stable/query.html). +The `execute_query()` function executes the query and returns either results in either a +JSON format, or a list of +[Python ICAT entity's](https://python-icat.readthedocs.io/en/stable/entity.html) (this +is defined using the `return_json_formattable` flag). Other functions within that class +are used within `execute_query()`. + + +## Generating the OpenAPI Specification +When the config option `generate_swagger` is set to true in `config.json`, a YAML +file defining the API using OpenAPI standards will be created at +`src/swagger/openapi.yaml`. This option should be disabled in production to avoid any +issues with read-only directories. + +[apispec](https://apispec.readthedocs.io/en/latest/) is used to help with this, with an +`APISpec()` object created in `src/main.py` which endpoint specifications are added to +(using `APISpec.path()`) when the endpoints are created for Flask. These paths are +iterated over and ordered alphabetically, to ensure `openapi.yaml` only changes if there +have been changes to the Swagger docs of the API; without that code, Git will detect +changes on that file everytime startup occurs (preventing a clean development repo). The +contents of the `APISpec` object are written to a YAML file and is used when the user +goes to the configured (root) page in their browser. + +The endpoint related files in `src/resources/` contain `__doc__` which have the Swagger +docs for each type of endpoint. For non-entity and table endpoints, the Swagger docs are +contained in the docstrings. `src/resources/swagger/` contain code to aid Swagger doc +generation, with a plugin (`RestfulPlugin`) created for `apispec` to extract Swagger +documentation from `flask-restful` functions. + + -#### Mapped classes -The classes mapped from the database are stored in `/common/database/models.py`. Each -model was automatically generated using sqlacodegen. A class `EntityHelper` is defined -so that each model may inherit two methods `to_dict()` and -`update_from_dict(dictionary)`, both used for returning entities and updating them, in a -form easily converted to JSON. +# Utilities +Within the repository, there are some useful files which can help with using the API. ## Database Generator -There is a tool to generate mock data into the database. It is located in +There is a tool to generate mock data into ICAT's database. It is located in `util/icat_db_generator.py`. By default it will generate 20 years worth of data (approx 70,000 entities). The script makes use of `random` and `Faker` and is seeded with a seed of 1. The seed and number of years of data generated can be changed by using the arg @@ -393,42 +691,54 @@ flags `-s` or `--seed` for the seed, and `-y` or `--years` for the number of yea example: `python -m util.icat_db_generator -s 4 -y 10` Would set the seed to 4 and generate 10 years of data. +This uses code from the API's Database Backend, so a suitable `DB_URL` should be +configured in `config.json`. -#### Querying and filtering: -The querying and filtering logic is located in `/common/database_helpers.py`. In this -module the abstract `Query` and `QueryFilter` classes are defined as well as their -implementations. The functions that are used by various endpoints to query the database -are also in this module. +## Postman Collection +With a handful of endpoints associated with each entity, there are hundreds of endpoints +for this API. A Postman collection is stored in the root directory of this repository, +containing over 300 requests, with each type of endpoint for every entity as well as the +table and session endpoints. The exported collection is in v2.1 format and is currently +the recommended export version for Postman. -#### Class diagrams for this module: -![image](https://user-images.githubusercontent.com/44777678/67954353-ba69ef80-fbe8-11e9-81e3-0668cea3fa35.png) -![image](https://user-images.githubusercontent.com/44777678/67954834-7fb48700-fbe9-11e9-96f3-ffefc7277ebd.png) +This collection is mainly based around the Python ICAT Backend (request bodies for +creating and updating data uses camelCase attribute names as accepted by that backend) +but can easily be adapted for using the Database Backend if needed (changing attribute +names to uppercase for example). The collection also contains a login request specially +for the Database Backend, as logging in using that backend is slightly different to +logging in via the Python ICAT Backend. +The repo's collection can be easily imported into your Postman installation by opening +Postman and selecting File > Import... and choosing the Postman collection from your +cloned DataGateway API repository. -#### Authentication -Each request requires a valid session ID to be provided in the Authorization header. -This header should take the form of `{"Authorization":"Bearer "}` A session -ID can be obtained by sending a post request to `/sessions/`. All endpoint methods that -require a session id are decorated with `@requires_session_id` -#### Generating the swagger spec: `openapi.yaml` -When the config option `generate_swagger` is set to true in `config.json`, a YAML -file defining the API using OpenAPI standards will be created at -`src/swagger/openapi.yaml`. [apispec](https://apispec.readthedocs.io/en/latest/) is used -to help with this, with an `APISpec()` object created in `src/main.py` which is added to -(using `APISpec.path()`) when the endpoints are created for Flask. These paths are -iterated over and ordered alphabetically, to ensure `openapi.yaml` only changes if there -have been changes to the Swagger docs of the API; without that code, Git will detect -changes on that file everytime startup occurs (preventing a clean development repo). The -contents of the `APISpec` object are written to a YAML file and is used when the user -goes to the configured (root) page in their browser. -The endpoint related files in `src/resources/` contain `__doc__` which have the Swagger -docs for each type of endpoint. `src/resources/swagger/` contain code to aid Swagger doc -generation, with a plugin (`RestfulPlugin`) created for `apispec` to extract Swagger -documentation from `flask-restful` functions. +# Updating README +Like the codebase, this README file follows a 88 character per line formatting approach. +This isn't always possible with URLs and codeblocks, but the vast majority of the file +should follow this approach. Most IDEs can be configured to include a guideline to show +where this point is. To do this in VS Code, insert the following line into +`settings.json`: + +```json +"editor.rulers": [ + 88 +] +``` + +Before a heading with a single hash, a four line gap should be given to easily indicate +separation between two sections. Before every other heading (i.e. headings with two or +more hashes), a two line gap should be given. This helps to denote a new heading rather +than just a new paragraph. While sections can be easily distinguished in a colourful +IDE, the multi-line spacing can be much easier to identify on an editor that doesn't use +colours. + +The directory tree found in the [project structure](#project-structure) can be generated +using the following command: -## Running Tests -To run the tests use `python -m unittest discover` + ```bash + git ls-tree -r --name-only HEAD | grep -v __init__.py | tree --fromfile + ``` diff --git a/config.json.example b/config.json.example index c977e9b0..8aab7943 100644 --- a/config.json.example +++ b/config.json.example @@ -8,5 +8,7 @@ "debug_mode": false, "generate_swagger": false, "host": "127.0.0.1", - "port": "5000" + "port": "5000", + "test_user_credentials": {"username": "root", "password": "pw"}, + "test_mechanism": "simple" } diff --git a/datagateway_api/common/backend.py b/datagateway_api/common/backend.py index c09d06a6..a6ca914a 100644 --- a/datagateway_api/common/backend.py +++ b/datagateway_api/common/backend.py @@ -172,7 +172,7 @@ def get_facility_cycles_for_instrument_count_with_filters( pass @abstractmethod - def get_investigations_for_instrument_in_facility_cycle_with_filters( + def get_investigations_for_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, ): """ @@ -188,7 +188,7 @@ def get_investigations_for_instrument_in_facility_cycle_with_filters( pass @abstractmethod - def get_investigation_count_for_instrument_facility_cycle_with_filters( + def get_investigation_count_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, ): """ diff --git a/datagateway_api/common/backends.py b/datagateway_api/common/backends.py index a124ec14..1cb92aa0 100644 --- a/datagateway_api/common/backends.py +++ b/datagateway_api/common/backends.py @@ -1,17 +1,30 @@ import sys -from datagateway_api.common.backend import Backend -from datagateway_api.common.config import config from datagateway_api.common.database.backend import DatabaseBackend from datagateway_api.common.icat.backend import PythonICATBackend -backend_type = config.get_backend_type() +def create_backend(backend_type): + """ + Create an instance of a backend dependent on the value parsed into the function. The + value will typically be from the contents of `config.json`, however when creating a + backend during automated tests the value will be from the Flask app's config (which + will be set in the API's config at `common.config` -if backend_type == "db": - backend = DatabaseBackend() -elif backend_type == "python_icat": - backend = PythonICATBackend() -else: - sys.exit(f"Invalid config value '{backend_type}' for config option backend") - backend = Backend() + The API will exit if a valid value isn't given. + + :param backend_type: The type of backend that should be created and used for the API + :type backend_type: :class:`str` + :return: Either an instance of `common.database.backend.DatabaseBackend` or + `common.icat.backend.PythonICATBackend` + """ + + if backend_type == "db": + backend = DatabaseBackend() + elif backend_type == "python_icat": + backend = PythonICATBackend() + else: + # Might turn to a warning so the abstract class can be tested? + sys.exit(f"Invalid config value '{backend_type}' for config option backend") + + return backend diff --git a/datagateway_api/common/config.py b/datagateway_api/common/config.py index 298fcadf..9b5090a4 100644 --- a/datagateway_api/common/config.py +++ b/datagateway_api/common/config.py @@ -10,11 +10,10 @@ class Config(object): - def __init__(self): - config_path = Path(__file__).parent.parent.parent / "config.json" - with open(config_path) as target: + def __init__(self, path=Path(__file__).parent.parent.parent / "config.json"): + self.path = path + with open(self.path) as target: self.config = json.load(target) - target.close() def get_backend_type(self): try: @@ -22,6 +21,19 @@ def get_backend_type(self): except KeyError: sys.exit("Missing config value, backend") + def set_backend_type(self, backend_type): + """ + This setter is used as a way for automated tests to set the backend type. The + API can detect if the Flask app setup is from an automated test by checking the + app's config for a `TEST_BACKEND`. If this value exists (a KeyError will be + raised when the API is run normally, which will then grab the backend type from + `config.json`), it needs to be set using this function. This is required because + creating filters in the `QueryFilterFactory` is backend-specific so the backend + type must be fetched. This must be done using this module (rather than directly + importing and checking the Flask app's config) to avoid circular import issues. + """ + self.config["backend"] = backend_type + def get_db_url(self): try: return self.config["DB_URL"] @@ -76,6 +88,18 @@ def get_port(self): except KeyError: sys.exit("Missing config value, port") + def get_test_user_credentials(self): + try: + return self.config["test_user_credentials"] + except KeyError: + sys.exit("Missing config value, test_user_credentials") + + def get_test_mechanism(self): + try: + return self.config["test_mechanism"] + except KeyError: + sys.exit("Missing config value, test_mechanism") + def get_icat_properties(self): """ ICAT properties can be retrieved using Python ICAT's client object, however this diff --git a/datagateway_api/common/constants.py b/datagateway_api/common/constants.py index f6e502e6..ce1ae9ac 100644 --- a/datagateway_api/common/constants.py +++ b/datagateway_api/common/constants.py @@ -1,3 +1,5 @@ +from datetime import datetime + from datagateway_api.common.config import config @@ -6,3 +8,4 @@ class Constants: ACCEPTED_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" PYTHON_ICAT_DISTNCT_CONDITION = "!= null" ICAT_PROPERTIES = config.get_icat_properties() + TEST_MOD_CREATE_DATETIME = datetime(2000, 1, 1) diff --git a/datagateway_api/common/database/backend.py b/datagateway_api/common/database/backend.py index 65d7eb6c..8ee01d87 100644 --- a/datagateway_api/common/database/backend.py +++ b/datagateway_api/common/database/backend.py @@ -124,7 +124,7 @@ def get_facility_cycles_for_instrument_count_with_filters( @requires_session_id @queries_records - def get_investigations_for_instrument_in_facility_cycle_with_filters( + def get_investigations_for_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, ): return get_investigations_for_instrument_in_facility_cycle( @@ -133,7 +133,7 @@ def get_investigations_for_instrument_in_facility_cycle_with_filters( @requires_session_id @queries_records - def get_investigation_count_for_instrument_facility_cycle_with_filters( + def get_investigation_count_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, ): return get_investigations_for_instrument_in_facility_cycle_count( diff --git a/datagateway_api/common/database/helpers.py b/datagateway_api/common/database/helpers.py index cf682cd8..55dbb40f 100644 --- a/datagateway_api/common/database/helpers.py +++ b/datagateway_api/common/database/helpers.py @@ -5,7 +5,10 @@ from sqlalchemy.orm import aliased -from datagateway_api.common.config import config +from datagateway_api.common.database.filters import ( + DatabaseIncludeFilter as IncludeFilter, + DatabaseWhereFilter as WhereFilter, +) from datagateway_api.common.database.models import ( FACILITY, FACILITYCYCLE, @@ -16,40 +19,13 @@ ) from datagateway_api.common.database.session_manager import session_manager from datagateway_api.common.exceptions import ( - ApiError, AuthenticationError, BadRequestError, - FilterError, MissingRecordError, ) from datagateway_api.common.filter_order_handler import FilterOrderHandler -backend_type = config.get_backend_type() -if backend_type == "db": - from datagateway_api.common.database.filters import ( - DatabaseDistinctFieldFilter as DistinctFieldFilter, - DatabaseIncludeFilter as IncludeFilter, - DatabaseLimitFilter as LimitFilter, - DatabaseOrderFilter as OrderFilter, - DatabaseSkipFilter as SkipFilter, - DatabaseWhereFilter as WhereFilter, - ) -elif backend_type == "python_icat": - from datagateway_api.common.icat.filters import ( - PythonICATDistinctFieldFilter as DistinctFieldFilter, - PythonICATIncludeFilter as IncludeFilter, - PythonICATLimitFilter as LimitFilter, - PythonICATOrderFilter as OrderFilter, - PythonICATSkipFilter as SkipFilter, - PythonICATWhereFilter as WhereFilter, - ) -else: - raise ApiError( - "Cannot select which implementation of filters to import, check the config file" - " has a valid backend type", - ) - log = logging.getLogger() @@ -202,42 +178,6 @@ def execute_query(self): self.commit_changes() -class QueryFilterFactory(object): - @staticmethod - def get_query_filter(request_filter): - """ - Given a filter return a matching Query filter object - - This factory is not in common.filters so the created filter can be for the - correct backend. Moving the factory into that file would mean the filters would - be based off the abstract classes (because they're in the same file) which won't - enable filters to be unique to the backend - - :param request_filter: dict - The filter to create the QueryFilter for - :return: The QueryFilter object created - """ - filter_name = list(request_filter)[0].lower() - if filter_name == "where": - field = list(request_filter[filter_name].keys())[0] - operation = list(request_filter[filter_name][field].keys())[0] - value = request_filter[filter_name][field][operation] - return WhereFilter(field, value, operation) - elif filter_name == "order": - field = request_filter["order"].split(" ")[0] - direction = request_filter["order"].split(" ")[1] - return OrderFilter(field, direction) - elif filter_name == "skip": - return SkipFilter(request_filter["skip"]) - elif filter_name == "limit": - return LimitFilter(request_filter["limit"]) - elif filter_name == "include": - return IncludeFilter(request_filter["include"]) - elif filter_name == "distinct": - return DistinctFieldFilter(request_filter["distinct"]) - else: - raise FilterError(f" Bad filter: {request_filter}") - - def insert_row_into_table(table, row): """ Insert the given row into its table @@ -285,7 +225,7 @@ def get_row_by_id(table, id_): :return: the record retrieved """ with ReadQuery(table) as read_query: - log.info(" Querying %s for record with ID: %d", table.__tablename__, id_) + log.info(" Querying %s for record with ID: %s", table.__tablename__, id_) where_filter = WhereFilter("ID", id_, "eq") where_filter.apply_filter(read_query) return read_query.get_single_result() @@ -299,7 +239,7 @@ def delete_row_by_id(table, id_): :param table: the table to be searched :param id_: the id of the record to delete """ - log.info(" Deleting row from %s with ID: %d", table.__tablename__, id_) + log.info(" Deleting row from %s with ID: %s", table.__tablename__, id_) row = get_row_by_id(table, id_) with DeleteQuery(table, row) as delete_query: delete_query.execute_query() @@ -389,7 +329,11 @@ def get_first_filtered_row(table, filters): :return: the first row matching the filter """ log.info(" Getting first filtered row for %s", table.__tablename__) - return get_rows_by_filter(table, filters)[0] + try: + result = get_rows_by_filter(table, filters)[0] + except IndexError: + raise MissingRecordError() + return result def get_filtered_row_count(table, filters): diff --git a/datagateway_api/common/filters.py b/datagateway_api/common/filters.py index 74d06dcc..a7844d46 100644 --- a/datagateway_api/common/filters.py +++ b/datagateway_api/common/filters.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod import logging -from datagateway_api.common.exceptions import BadRequestError +from datagateway_api.common.exceptions import BadRequestError, FilterError log = logging.getLogger() @@ -55,14 +55,20 @@ class SkipFilter(QueryFilter): precedence = 3 def __init__(self, skip_value): - self.skip_value = skip_value + if skip_value >= 0: + self.skip_value = skip_value + else: + raise FilterError("The value of the skip filter must be positive") class LimitFilter(QueryFilter): precedence = 4 def __init__(self, limit_value): - self.limit_value = limit_value + if limit_value >= 0: + self.limit_value = limit_value + else: + raise FilterError("The value of the limit filter must be positive") class IncludeFilter(QueryFilter): diff --git a/datagateway_api/common/helpers.py b/datagateway_api/common/helpers.py index 9115b09f..a6562394 100644 --- a/datagateway_api/common/helpers.py +++ b/datagateway_api/common/helpers.py @@ -6,7 +6,6 @@ from flask_restful import reqparse from sqlalchemy.exc import IntegrityError -from datagateway_api.common.database.helpers import QueryFilterFactory from datagateway_api.common.exceptions import ( ApiError, AuthenticationError, @@ -14,6 +13,7 @@ FilterError, MissingCredentialsError, ) +from datagateway_api.common.query_filter_factory import QueryFilterFactory log = logging.getLogger() diff --git a/datagateway_api/common/icat/backend.py b/datagateway_api/common/icat/backend.py index af9c14dd..2770f190 100644 --- a/datagateway_api/common/icat/backend.py +++ b/datagateway_api/common/icat/backend.py @@ -137,7 +137,7 @@ def get_facility_cycles_for_instrument_count_with_filters( @requires_session_id @queries_records - def get_investigations_for_instrument_in_facility_cycle_with_filters( + def get_investigations_for_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, **kwargs, ): client = kwargs["client"] if kwargs["client"] else create_client() @@ -147,7 +147,7 @@ def get_investigations_for_instrument_in_facility_cycle_with_filters( @requires_session_id @queries_records - def get_investigation_count_for_instrument_facility_cycle_with_filters( + def get_investigation_count_instrument_facility_cycle_with_filters( self, session_id, instrument_id, facilitycycle_id, filters, **kwargs, ): client = kwargs["client"] if kwargs["client"] else create_client() diff --git a/datagateway_api/common/icat/helpers.py b/datagateway_api/common/icat/helpers.py index 83fce7a2..3b4fde95 100644 --- a/datagateway_api/common/icat/helpers.py +++ b/datagateway_api/common/icat/helpers.py @@ -169,6 +169,7 @@ def update_attributes(old_entity, new_entity): - typically if Python ICAT doesn't allow an attribute to be edited (e.g. modId & modTime) """ + log.debug("Updating entity attributes: %s", list(new_entity.keys())) for key in new_entity: try: original_data_attribute = getattr(old_entity, key) @@ -194,8 +195,10 @@ def update_attributes(old_entity, new_entity): def push_data_updates_to_icat(entity): try: entity.update() - except (ICATValidationError, ICATInternalError) as e: + except ICATInternalError as e: raise PythonICATError(e) + except ICATValidationError as e: + raise BadRequestError(e) def get_entity_by_id( @@ -517,13 +520,13 @@ def create_entities(client, entity_type, data): for entity in created_icat_data: try: entity.create() - except (ICATValidationError, ICATInternalError) as e: + except ICATInternalError as e: for entity_json in created_data: # Delete any data that has been pushed to ICAT before the exception delete_entity_by_id(client, entity_type, entity_json["id"]) raise PythonICATError(e) - except (ICATObjectExistsError, ICATParameterError) as e: + except (ICATObjectExistsError, ICATParameterError, ICATValidationError) as e: for entity_json in created_data: delete_entity_by_id(client, entity_type, entity_json["id"]) diff --git a/datagateway_api/common/icat/query.py b/datagateway_api/common/icat/query.py index 0c32c538..179aab94 100644 --- a/datagateway_api/common/icat/query.py +++ b/datagateway_api/common/icat/query.py @@ -325,4 +325,4 @@ def flatten_query_included_fields(self, includes): ICAT query """ - return [m for n in (field.split(".") for field in includes) for m in n] + return [m for n in (field.split(".") for field in sorted(includes)) for m in n] diff --git a/datagateway_api/common/query_filter_factory.py b/datagateway_api/common/query_filter_factory.py new file mode 100644 index 00000000..e4e90308 --- /dev/null +++ b/datagateway_api/common/query_filter_factory.py @@ -0,0 +1,74 @@ +import logging + +from datagateway_api.common.config import config +from datagateway_api.common.exceptions import ( + ApiError, + FilterError, +) + +log = logging.getLogger() + + +class QueryFilterFactory(object): + @staticmethod + def get_query_filter(request_filter): + """ + Given a filter, return a matching Query filter object + + The filters are imported inside this method to enable the unit tests to not rely + on the contents of `config.json`. If they're imported at the top of the file, + the backend type won't have been updated if the Flask app has been created from + an automated test (file imports occur before `create_api_endpoints()` executes). + + :param request_filter: The filter to create the QueryFilter for + :type request_filter: :class:`dict` + :return: The QueryFilter object created + :raises ApiError: If the backend type contains an invalid value + :raises FilterError: If the filter name is not recognised + """ + + backend_type = config.get_backend_type() + if backend_type == "db": + from datagateway_api.common.database.filters import ( + DatabaseDistinctFieldFilter as DistinctFieldFilter, + DatabaseIncludeFilter as IncludeFilter, + DatabaseLimitFilter as LimitFilter, + DatabaseOrderFilter as OrderFilter, + DatabaseSkipFilter as SkipFilter, + DatabaseWhereFilter as WhereFilter, + ) + elif backend_type == "python_icat": + from datagateway_api.common.icat.filters import ( + PythonICATDistinctFieldFilter as DistinctFieldFilter, + PythonICATIncludeFilter as IncludeFilter, + PythonICATLimitFilter as LimitFilter, + PythonICATOrderFilter as OrderFilter, + PythonICATSkipFilter as SkipFilter, + PythonICATWhereFilter as WhereFilter, + ) + else: + raise ApiError( + "Cannot select which implementation of filters to import, check the" + " config file has a valid backend type", + ) + + filter_name = list(request_filter)[0].lower() + if filter_name == "where": + field = list(request_filter[filter_name].keys())[0] + operation = list(request_filter[filter_name][field].keys())[0] + value = request_filter[filter_name][field][operation] + return WhereFilter(field, value, operation) + elif filter_name == "order": + field = request_filter["order"].split(" ")[0] + direction = request_filter["order"].split(" ")[1] + return OrderFilter(field, direction) + elif filter_name == "skip": + return SkipFilter(request_filter["skip"]) + elif filter_name == "limit": + return LimitFilter(request_filter["limit"]) + elif filter_name == "include": + return IncludeFilter(request_filter["include"]) + elif filter_name == "distinct": + return DistinctFieldFilter(request_filter["distinct"]) + else: + raise FilterError(f" Bad filter: {request_filter}") diff --git a/datagateway_api/src/main.py b/datagateway_api/src/main.py index d971d724..22eb13df 100644 --- a/datagateway_api/src/main.py +++ b/datagateway_api/src/main.py @@ -8,6 +8,7 @@ from flask_restful import Api from flask_swagger_ui import get_swaggerui_blueprint +from datagateway_api.common.backends import create_backend from datagateway_api.common.config import config from datagateway_api.common.logger_setup import setup_logger from datagateway_api.src.resources.entities.entity_endpoint import ( @@ -17,24 +18,23 @@ get_id_endpoint, ) from datagateway_api.src.resources.entities.entity_map import endpoints -from datagateway_api.src.resources.non_entities.sessions_endpoints import Sessions +from datagateway_api.src.resources.non_entities.sessions_endpoints import ( + session_endpoints, +) from datagateway_api.src.resources.table_endpoints.table_endpoints import ( - InstrumentsFacilityCycles, - InstrumentsFacilityCyclesCount, - InstrumentsFacilityCyclesInvestigations, - InstrumentsFacilityCyclesInvestigationsCount, + count_instrument_facility_cycles_endpoint, + count_instrument_investigation_endpoint, + instrument_facility_cycles_endpoint, + instrument_investigation_endpoint, ) from datagateway_api.src.swagger.apispec_flask_restful import RestfulPlugin from datagateway_api.src.swagger.initialise_spec import initialise_spec +setup_logger() +log = logging.getLogger() +log.info("Logging now setup") -spec = APISpec( - title="DataGateway API", - version="1.0", - openapi_version="3.0.3", - plugins=[RestfulPlugin()], - security=[{"session_id": []}], -) +app = Flask(__name__) class CustomErrorHandledApi(Api): @@ -47,76 +47,115 @@ def handle_error(self, e): return str(e), e.status_code -app = Flask(__name__) -cors = CORS(app) -app.url_map.strict_slashes = False -api = CustomErrorHandledApi(app) +def create_app_infrastructure(flask_app): + swaggerui_blueprint = get_swaggerui_blueprint( + "", "/openapi.json", config={"app_name": "DataGateway API OpenAPI Spec"}, + ) + flask_app.register_blueprint(swaggerui_blueprint, url_prefix="/") + spec = APISpec( + title="DataGateway API", + version="1.0", + openapi_version="3.0.3", + plugins=[RestfulPlugin()], + security=[{"session_id": []}], + ) -swaggerui_blueprint = get_swaggerui_blueprint( - "", "/openapi.json", config={"app_name": "DataGateway API OpenAPI Spec"}, -) + CORS(flask_app) + flask_app.url_map.strict_slashes = False + api = CustomErrorHandledApi(flask_app) -app.register_blueprint(swaggerui_blueprint, url_prefix="/") + initialise_spec(spec) -setup_logger() -log = logging.getLogger() -log.info("Logging now setup") + return (api, spec) -initialise_spec(spec) -for entity_name in endpoints: - get_endpoint_resource = get_endpoint(entity_name, endpoints[entity_name]) - api.add_resource(get_endpoint_resource, f"/{entity_name.lower()}") - spec.path(resource=get_endpoint_resource, api=api) +def handle_error(e): + return str(e), e.status_code - get_id_endpoint_resource = get_id_endpoint(entity_name, endpoints[entity_name]) - api.add_resource(get_id_endpoint_resource, f"/{entity_name.lower()}/") - spec.path(resource=get_id_endpoint_resource, api=api) - get_count_endpoint_resource = get_count_endpoint( - entity_name, endpoints[entity_name], - ) - api.add_resource(get_count_endpoint_resource, f"/{entity_name.lower()}/count") - spec.path(resource=get_count_endpoint_resource, api=api) +def create_api_endpoints(flask_app, api, spec): + try: + backend_type = flask_app.config["TEST_BACKEND"] + config.set_backend_type(backend_type) + except KeyError: + backend_type = config.get_backend_type() - get_find_one_endpoint_resource = get_find_one_endpoint( - entity_name, endpoints[entity_name], + backend = create_backend(backend_type) + + for entity_name in endpoints: + get_endpoint_resource = get_endpoint( + entity_name, endpoints[entity_name], backend, + ) + api.add_resource(get_endpoint_resource, f"/{entity_name.lower()}") + spec.path(resource=get_endpoint_resource, api=api) + + get_id_endpoint_resource = get_id_endpoint( + entity_name, endpoints[entity_name], backend, + ) + api.add_resource(get_id_endpoint_resource, f"/{entity_name.lower()}/") + spec.path(resource=get_id_endpoint_resource, api=api) + + get_count_endpoint_resource = get_count_endpoint( + entity_name, endpoints[entity_name], backend, + ) + api.add_resource(get_count_endpoint_resource, f"/{entity_name.lower()}/count") + spec.path(resource=get_count_endpoint_resource, api=api) + + get_find_one_endpoint_resource = get_find_one_endpoint( + entity_name, endpoints[entity_name], backend, + ) + api.add_resource( + get_find_one_endpoint_resource, f"/{entity_name.lower()}/findone", + ) + spec.path(resource=get_find_one_endpoint_resource, api=api) + + # Session endpoint + session_endpoint_resource = session_endpoints(backend) + api.add_resource(session_endpoint_resource, "/sessions") + spec.path(resource=session_endpoint_resource, api=api) + + # Table specific endpoints + instrument_facility_cycle_resource = instrument_facility_cycles_endpoint(backend) + api.add_resource( + instrument_facility_cycle_resource, "/instruments//facilitycycles", ) - api.add_resource(get_find_one_endpoint_resource, f"/{entity_name.lower()}/findone") - spec.path(resource=get_find_one_endpoint_resource, api=api) + spec.path(resource=instrument_facility_cycle_resource, api=api) + count_instrument_facility_cycle_res = count_instrument_facility_cycles_endpoint( + backend, + ) + api.add_resource( + count_instrument_facility_cycle_res, + "/instruments//facilitycycles/count", + ) + spec.path(resource=count_instrument_facility_cycle_res, api=api) -# Session endpoint -api.add_resource(Sessions, "/sessions") -spec.path(resource=Sessions, api=api) + instrument_investigation_resource = instrument_investigation_endpoint(backend) + api.add_resource( + instrument_investigation_resource, + "/instruments//facilitycycles//investigations", + ) + spec.path(resource=instrument_investigation_resource, api=api) -# Table specific endpoints -api.add_resource(InstrumentsFacilityCycles, "/instruments//facilitycycles") -spec.path(resource=InstrumentsFacilityCycles, api=api) -api.add_resource( - InstrumentsFacilityCyclesCount, "/instruments//facilitycycles/count", -) -spec.path(resource=InstrumentsFacilityCyclesCount, api=api) -api.add_resource( - InstrumentsFacilityCyclesInvestigations, - "/instruments//facilitycycles//investigations", -) -spec.path(resource=InstrumentsFacilityCyclesInvestigations, api=api) -api.add_resource( - InstrumentsFacilityCyclesInvestigationsCount, - "/instruments//facilitycycles//investigations" - "/count", -) -spec.path(resource=InstrumentsFacilityCyclesInvestigationsCount, api=api) + count_instrument_investigation_resource = count_instrument_investigation_endpoint( + backend, + ) + api.add_resource( + count_instrument_investigation_resource, + "/instruments//facilitycycles//investigations" + "/count", + ) + spec.path(resource=count_instrument_investigation_resource, api=api) -if config.is_generate_swagger(): +def openapi_config(spec): # Reorder paths (e.g. get, patch, post) so openapi.yaml only changes when there's a # change to the Swagger docs, rather than changing on each startup - log.debug("Reordering OpenAPI docs to alphabetical order") - for entity_data in spec._paths.values(): - for endpoint_name in sorted(entity_data.keys()): - entity_data.move_to_end(endpoint_name) + if config.is_generate_swagger(): + log.debug("Reordering OpenAPI docs to alphabetical order") + for entity_data in spec._paths.values(): + for endpoint_name in sorted(entity_data.keys()): + entity_data.move_to_end(endpoint_name) openapi_spec_path = Path(__file__).parent / "swagger/openapi.yaml" with open(openapi_spec_path, "w") as f: @@ -131,6 +170,9 @@ def specs(): if __name__ == "__main__": + api, spec = create_app_infrastructure(app) + create_api_endpoints(app, api, spec) + openapi_config(spec) app.run( host=config.get_host(), port=config.get_port(), debug=config.is_debug_mode(), ) diff --git a/datagateway_api/src/resources/entities/entity_endpoint.py b/datagateway_api/src/resources/entities/entity_endpoint.py index 2058b345..ef26e844 100644 --- a/datagateway_api/src/resources/entities/entity_endpoint.py +++ b/datagateway_api/src/resources/entities/entity_endpoint.py @@ -1,21 +1,24 @@ from flask import request from flask_restful import Resource -from datagateway_api.common.backends import backend from datagateway_api.common.helpers import ( get_filters_from_query_string, get_session_id_from_auth_header, ) -def get_endpoint(name, entity_type): +def get_endpoint(name, entity_type, backend): """ Given an entity name generate a flask_restful Resource class. In main.py these generated classes are registered with the api e.g api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles") :param name: The name of the entity + :type name: :class:`str` :param entity_type: The entity the endpoint will use in queries + :type entity_type: :class:`str` + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` :return: The generated endpoint class """ @@ -156,14 +159,18 @@ def patch(self): return Endpoint -def get_id_endpoint(name, entity_type): +def get_id_endpoint(name, entity_type, backend): """ Given an entity name generate a flask_restful Resource class. In main.py these generated classes are registered with the api e.g api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles/") :param name: The name of the entity + :type name: :class:`str` :param entity_type: The entity the endpoint will use in queries + :type entity_type: :class:`str` + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` :return: The generated id endpoint class """ @@ -286,14 +293,18 @@ def patch(self, id_): return EndpointWithID -def get_count_endpoint(name, entity_type): +def get_count_endpoint(name, entity_type, backend): """ Given an entity name generate a flask_restful Resource class. In main.py these generated classes are registered with the api e.g api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles/count") :param name: The name of the entity + :type name: :class:`str` :param entity_type: The entity the endpoint will use in queries + :type entity_type: :class:`str` + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` :return: The generated count endpoint class """ @@ -339,14 +350,18 @@ def get(self): return CountEndpoint -def get_find_one_endpoint(name, entity_type): +def get_find_one_endpoint(name, entity_type, backend): """ Given an entity name generate a flask_restful Resource class. In main.py these generated classes are registered with the api e.g api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles/findone") :param name: The name of the entity + :type name: :class:`str` :param entity_type: The entity the endpoint will use in queries + :type entity_type: :class:`str` + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` :return: The generated findOne endpoint class """ diff --git a/datagateway_api/src/resources/non_entities/sessions_endpoints.py b/datagateway_api/src/resources/non_entities/sessions_endpoints.py index 348d719c..7e5b2a6b 100644 --- a/datagateway_api/src/resources/non_entities/sessions_endpoints.py +++ b/datagateway_api/src/resources/non_entities/sessions_endpoints.py @@ -3,7 +3,6 @@ from flask import request from flask_restful import Resource -from datagateway_api.common.backends import backend from datagateway_api.common.exceptions import AuthenticationError from datagateway_api.common.helpers import get_session_id_from_auth_header @@ -11,142 +10,157 @@ log = logging.getLogger() -class Sessions(Resource): - def post(self): - """ - Generates a sessionID if the user has correct credentials - :return: String - SessionID +def session_endpoints(backend): + """ + Generate a flask_restful Resource class using the configured backend. In main.py + these generated classes are registered with the api e.g. + `api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles")` - --- - summary: Login - description: Generates a sessionID if the user has correct credentials - tags: - - Sessions - security: [] - requestBody: - description: User credentials to login with - required: true - content: - application/json: - schema: - type: object - properties: - username: - type: string - password: - type: string - mechanism: - type: string - responses: - 201: - description: Success - returns a session ID - content: - application/json: - schema: - type: object - properties: - sessionID: - type: string - description: Session ID - example: xxxxxx-yyyyyyy-zzzzzz - 400: - description: Bad request - User credentials not provided in request body - 403: - description: Forbidden - User credentials were invalid - """ - if not ( - request.data and "username" in request.json and "password" in request.json - ): - return "Bad request", 400 - # If no mechanism is present in request body, default to simple - if not ("mechanism" in request.json): - request.json["mechanism"] = "simple" - try: - return {"sessionID": backend.login(request.json)}, 201 - except AuthenticationError: - return "Forbidden", 403 + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` + :return: The generated session endpoint class + """ + + class Sessions(Resource): + def post(self): + """ + Generates a sessionID if the user has correct credentials + :return: String - SessionID + + --- + summary: Login + description: Generates a sessionID if the user has correct credentials + tags: + - Sessions + security: [] + requestBody: + description: User credentials to login with + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + mechanism: + type: string + responses: + 201: + description: Success - returns a session ID + content: + application/json: + schema: + type: object + properties: + sessionID: + type: string + description: Session ID + example: xxxxxx-yyyyyyy-zzzzzz + 400: + description: Bad request - User credentials not provided in request body + 403: + description: Forbidden - User credentials were invalid + """ + if not ( + request.data + and "username" in request.json + and "password" in request.json + ): + return "Bad request", 400 + # If no mechanism is present in request body, default to simple + if not ("mechanism" in request.json): + request.json["mechanism"] = "simple" + try: + return {"sessionID": backend.login(request.json)}, 201 + except AuthenticationError: + return "Forbidden", 403 - def delete(self): - """ - Deletes a users sessionID when they logout - :return: Blank response, 200 - --- - summary: Delete session - description: Deletes a users sessionID when they logout - tags: - - Sessions - responses: - 200: - description: Success - User's session was successfully deleted - 400: - description: Bad request - something was wrong with the request - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - 404: - description: Not Found - Unable to find session ID - """ - backend.logout(get_session_id_from_auth_header()) - return "", 200 + def delete(self): + """ + Deletes a users sessionID when they logout + :return: Blank response, 200 + --- + summary: Delete session + description: Deletes a users sessionID when they logout + tags: + - Sessions + responses: + 200: + description: Success - User's session was successfully deleted + 400: + description: Bad request - something was wrong with the request + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + 404: + description: Not Found - Unable to find session ID + """ + backend.logout(get_session_id_from_auth_header()) + return "", 200 - def get(self): - """ - Gives details of a users session - :return: String: Details of the session, 200 - --- - summary: Get session details - description: Gives details of a user's session - tags: - - Sessions - responses: - 200: - description: Success - a user's session details - content: - application/json: - schema: - type: object - properties: - ID: + def get(self): + """ + Gives details of a users session + :return: String: Details of the session, 200 + --- + summary: Get session details + description: Gives details of a user's session + tags: + - Sessions + responses: + 200: + description: Success - a user's session details + content: + application/json: + schema: + type: object + properties: + ID: + type: string + description: The session ID + example: xxxxxx-yyyyyyy-zzzzzz + EXPIREDATETIME: + type: string + format: datetime + description: When this session expires + example: "2017-07-21T17:32:28Z" + USERNAME: + type: string + description: Username associated with this session + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + """ + return backend.get_session_details(get_session_id_from_auth_header()), 200 + + def put(self): + """ + Refreshes a users session + :return: String: The session ID that has been refreshed, 200 + --- + summary: Refresh session + description: Refreshes a users session + tags: + - Sessions + responses: + 200: + description: Success - the user's session ID that has been refreshed + content: + application/json: + schema: type: string - description: The session ID + description: Session ID example: xxxxxx-yyyyyyy-zzzzzz - EXPIREDATETIME: - type: string - format: datetime - description: When this session expires - example: "2017-07-21T17:32:28Z" - USERNAME: - type: string - description: Username associated with this session - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - """ - return backend.get_session_details(get_session_id_from_auth_header()), 200 + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + """ + return backend.refresh(get_session_id_from_auth_header()), 200 - def put(self): - """ - Refreshes a users session - :return: String: The session ID that has been refreshed, 200 - --- - summary: Refresh session - description: Refreshes a users session - tags: - - Sessions - responses: - 200: - description: Success - the user's session ID that has been refreshed - content: - application/json: - schema: - type: string - description: Session ID - example: xxxxxx-yyyyyyy-zzzzzz - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - """ - return backend.refresh(get_session_id_from_auth_header()), 200 + return Sessions diff --git a/datagateway_api/src/resources/table_endpoints/table_endpoints.py b/datagateway_api/src/resources/table_endpoints/table_endpoints.py index b8e4744f..3c73c406 100644 --- a/datagateway_api/src/resources/table_endpoints/table_endpoints.py +++ b/datagateway_api/src/resources/table_endpoints/table_endpoints.py @@ -1,212 +1,277 @@ from flask_restful import Resource -from datagateway_api.common.backends import backend from datagateway_api.common.helpers import ( get_filters_from_query_string, get_session_id_from_auth_header, ) -class InstrumentsFacilityCycles(Resource): - def get(self, id_): - """ - --- - summary: Get an Instrument's FacilityCycles - description: Given an Instrument id get facility cycles where the instrument has - investigations that occur within that cycle, subject to the given filters - tags: - - FacilityCycles - parameters: - - in: path - required: true - name: id_ - description: The id of the instrument to retrieve the facility cycles of - schema: - type: integer - - WHERE_FILTER - - ORDER_FILTER - - LIMIT_FILTER - - SKIP_FILTER - - DISTINCT_FILTER - - INCLUDE_FILTER - responses: - 200: - description: Success - returns a list of the instrument's facility - cycles that satisfy the filters - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FACILITYCYCLE' - 400: - description: Bad request - Something was wrong with the request - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - 404: - description: No such record - Unable to find a record in ICAT - """ - return ( - backend.get_facility_cycles_for_instrument_with_filters( - get_session_id_from_auth_header(), id_, get_filters_from_query_string(), - ), - 200, - ) - - -class InstrumentsFacilityCyclesCount(Resource): - def get(self, id_): - """ - --- - summary: Count an Instrument's FacilityCycles - description: Return the count of the Facility Cycles that have investigations - that occur within that cycle on the specified instrument that would be - retrieved given the filters provided - tags: - - FacilityCycles - parameters: - - in: path - required: true - name: id_ - description: The id of the instrument to count the facility cycles of - schema: - type: integer - - WHERE_FILTER - - DISTINCT_FILTER - responses: - 200: - description: Success - The count of the instrument's facility cycles - that satisfy the filters - content: - application/json: - schema: - type: integer - 400: - description: Bad request - Something was wrong with the request - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - 404: - description: No such record - Unable to find a record in ICAT - """ - return ( - backend.get_facility_cycles_for_instrument_count_with_filters( - get_session_id_from_auth_header(), id_, get_filters_from_query_string(), - ), - 200, - ) - - -class InstrumentsFacilityCyclesInvestigations(Resource): - def get(self, instrument_id, cycle_id): - """ - --- - summary: Get the investigations for a given Facility Cycle & Instrument - description: Given an Instrument id and Facility Cycle id, get the - investigations that occur within that cycle on that instrument, subject to - the given filters - tags: - - Investigations - parameters: - - in: path - required: true - name: instrument_id - description: The id of the instrument to retrieve the investigations of - schema: - type: integer - - in: path - required: true - name: cycle_id - description: The id of the facility cycle to retrieve the investigations - schema: - type: integer - - WHERE_FILTER - - ORDER_FILTER - - LIMIT_FILTER - - SKIP_FILTER - - DISTINCT_FILTER - - INCLUDE_FILTER - responses: - 200: - description: Success - returns a list of the investigations for the - given instrument and facility cycle that satisfy the filters - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/INVESTIGATION' - 400: - description: Bad request - Something was wrong with the request - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - 404: - description: No such record - Unable to find a record in ICAT - """ - return ( - backend.get_investigations_for_instrument_in_facility_cycle_with_filters( - get_session_id_from_auth_header(), - instrument_id, - cycle_id, - get_filters_from_query_string(), - ), - 200, - ) - - -class InstrumentsFacilityCyclesInvestigationsCount(Resource): - def get(self, instrument_id, cycle_id): - """ - --- - summary: Count investigations for a given Facility Cycle & Instrument - description: Given an Instrument id and Facility Cycle id, get the number of - investigations that occur within that cycle on that instrument, subject to - the given filters - tags: - - Investigations - parameters: - - in: path - required: true - name: instrument_id - description: The id of the instrument to retrieve the investigations of - schema: - type: integer - - in: path - required: true - name: cycle_id - description: The id of the facility cycle to retrieve the investigations - schema: - type: integer - - WHERE_FILTER - - DISTINCT_FILTER - responses: - 200: - description: Success - The count of the investigations for the given - instrument and facility cycle that satisfy the filters - content: - application/json: - schema: - type: integer - 400: - description: Bad request - Something was wrong with the request - 401: - description: Unauthorized - No session ID found in HTTP Auth. header - 403: - description: Forbidden - The session ID provided is invalid - 404: - description: No such record - Unable to find a record in ICAT - """ - return ( - backend.get_investigation_count_for_instrument_facility_cycle_with_filters( - get_session_id_from_auth_header(), - instrument_id, - cycle_id, - get_filters_from_query_string(), - ), - 200, - ) +def instrument_facility_cycles_endpoint(backend): + """ + Generate a flask_restful Resource class using the configured backend. In main.py + these generated classes are registered with the api e.g. + `api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles")` + + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` + :return: The generated endpoint class + """ + pass + + class InstrumentsFacilityCycles(Resource): + def get(self, id_): + """ + --- + summary: Get an Instrument's FacilityCycles + description: Given an Instrument id get facility cycles where the instrument + has investigations that occur within that cycle, subject to the given + filters + tags: + - FacilityCycles + parameters: + - in: path + required: true + name: id_ + description: The id of the instrument to retrieve the facility cycles + of + schema: + type: integer + - WHERE_FILTER + - ORDER_FILTER + - LIMIT_FILTER + - SKIP_FILTER + - DISTINCT_FILTER + - INCLUDE_FILTER + responses: + 200: + description: Success - returns a list of the instrument's facility + cycles that satisfy the filters + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FACILITYCYCLE' + 400: + description: Bad request - Something was wrong with the request + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + 404: + description: No such record - Unable to find a record in ICAT + """ + return ( + backend.get_facility_cycles_for_instrument_with_filters( + get_session_id_from_auth_header(), + id_, + get_filters_from_query_string(), + ), + 200, + ) + + return InstrumentsFacilityCycles + + +def count_instrument_facility_cycles_endpoint(backend): + """ + Generate a flask_restful Resource class using the configured backend. In main.py + these generated classes are registered with the api e.g. + `api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles")` + + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` + :return: The generated endpoint class + """ + pass + + class InstrumentsFacilityCyclesCount(Resource): + def get(self, id_): + """ + --- + summary: Count an Instrument's FacilityCycles + description: Return the count of the Facility Cycles that have + investigations that occur within that cycle on the specified instrument + that would be retrieved given the filters provided + tags: + - FacilityCycles + parameters: + - in: path + required: true + name: id_ + description: The id of the instrument to count the facility cycles of + schema: + type: integer + - WHERE_FILTER + - DISTINCT_FILTER + responses: + 200: + description: Success - The count of the instrument's facility cycles + that satisfy the filters + content: + application/json: + schema: + type: integer + 400: + description: Bad request - Something was wrong with the request + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + 404: + description: No such record - Unable to find a record in ICAT + """ + return ( + backend.get_facility_cycles_for_instrument_count_with_filters( + get_session_id_from_auth_header(), + id_, + get_filters_from_query_string(), + ), + 200, + ) + + return InstrumentsFacilityCyclesCount + + +def instrument_investigation_endpoint(backend): + """ + Generate a flask_restful Resource class using the configured backend. In main.py + these generated classes are registered with the api e.g. + `api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles")` + + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` + :return: The generated endpoint class + """ + pass + + class InstrumentsFacilityCyclesInvestigations(Resource): + def get(self, instrument_id, cycle_id): + """ + --- + summary: Get the investigations for a given Facility Cycle & Instrument + description: Given an Instrument id and Facility Cycle id, get the + investigations that occur within that cycle on that instrument, subject + to the given filters + tags: + - Investigations + parameters: + - in: path + required: true + name: instrument_id + description: The id of the instrument to retrieve the investigations + of + schema: + type: integer + - in: path + required: true + name: cycle_id + description: The id of the facility cycle to retrieve the + investigations + schema: + type: integer + - WHERE_FILTER + - ORDER_FILTER + - LIMIT_FILTER + - SKIP_FILTER + - DISTINCT_FILTER + - INCLUDE_FILTER + responses: + 200: + description: Success - returns a list of the investigations for the + given instrument and facility cycle that satisfy the filters + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/INVESTIGATION' + 400: + description: Bad request - Something was wrong with the request + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + 404: + description: No such record - Unable to find a record in ICAT + """ + return ( + backend.get_investigations_for_instrument_facility_cycle_with_filters( + get_session_id_from_auth_header(), + instrument_id, + cycle_id, + get_filters_from_query_string(), + ), + 200, + ) + + return InstrumentsFacilityCyclesInvestigations + + +def count_instrument_investigation_endpoint(backend): + """ + Generate a flask_restful Resource class using the configured backend. In main.py + these generated classes are registered with the api e.g. + `api.add_resource(get_endpoint("Datafiles", DATAFILE), "/datafiles")` + + :param backend: The backend instance used for processing requests + :type backend: :class:`DatabaseBackend` or :class:`PythonICATBackend` + :return: The generated endpoint class + """ + pass + + class InstrumentsFacilityCyclesInvestigationsCount(Resource): + def get(self, instrument_id, cycle_id): + """ + --- + summary: Count investigations for a given Facility Cycle & Instrument + description: Given an Instrument id and Facility Cycle id, get the number of + investigations that occur within that cycle on that instrument, subject + to the given filters + tags: + - Investigations + parameters: + - in: path + required: true + name: instrument_id + description: The id of the instrument to retrieve the investigations + of + schema: + type: integer + - in: path + required: true + name: cycle_id + description: The id of the facility cycle to retrieve the + investigations + schema: + type: integer + - WHERE_FILTER + - DISTINCT_FILTER + responses: + 200: + description: Success - The count of the investigations for the given + instrument and facility cycle that satisfy the filters + content: + application/json: + schema: + type: integer + 400: + description: Bad request - Something was wrong with the request + 401: + description: Unauthorized - No session ID found in HTTP Auth. header + 403: + description: Forbidden - The session ID provided is invalid + 404: + description: No such record - Unable to find a record in ICAT + """ + return ( + backend.get_investigation_count_instrument_facility_cycle_with_filters( + get_session_id_from_auth_header(), + instrument_id, + cycle_id, + get_filters_from_query_string(), + ), + 200, + ) + + return InstrumentsFacilityCyclesInvestigationsCount diff --git a/noxfile.py b/noxfile.py index c6480775..6960f90d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -4,7 +4,7 @@ import nox # Separating Black away from the rest of the sessions -nox.options.sessions = "lint", "safety" +nox.options.sessions = "lint", "safety", "tests" code_locations = "datagateway_api", "test", "util", "noxfile.py" @@ -17,6 +17,7 @@ def install_with_constraints(session, *args, **kwargs): "export", "--dev", "--format=requirements.txt", + "--without-hashes", f"--output={requirements.name}", external=True, ) @@ -79,3 +80,10 @@ def safety(session): os.unlink(requirements.name) except IOError: session.log("Error: The temporary requirements file could not be closed") + + +@nox.session(python=["3.6", "3.7", "3.8"], reuse_venv=True) +def tests(session): + args = session.posargs + session.run("poetry", "install", external=True) + session.run("pytest", *args) diff --git a/poetry.lock b/poetry.lock index 2ba5d2c3..037b6641 100644 --- a/poetry.lock +++ b/poetry.lock @@ -30,17 +30,25 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "attrs" -version = "20.2.0" +version = "20.3.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] @@ -81,7 +89,7 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "certifi" -version = "2020.6.20" +version = "2020.11.8" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -111,6 +119,20 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.dependencies] +toml = {version = "*", optional = true, markers = "extra == \"toml\""} + +[package.extras] +toml = ["toml"] + [[package]] name = "dparse" version = "0.5.1" @@ -351,6 +373,14 @@ python-versions = ">=3.4" [package.dependencies] gitdb = ">=4.0.1,<5" +[[package]] +name = "icdiff" +version = "1.9.1" +description = "improved colored diff" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "idna" version = "2.10" @@ -374,6 +404,14 @@ zipp = ">=0.5" docs = ["sphinx", "rst.linker"] testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "itsdangerous" version = "1.1.0" @@ -426,7 +464,7 @@ six = "*" [[package]] name = "pathspec" -version = "0.8.0" +version = "0.8.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false @@ -467,6 +505,36 @@ six = "*" coverage = ["pytest-cov"] testing = ["mock", "pytest", "pytest-rerunfailures"] +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "pprintpp" +version = "0.4.0" +description = "A drop-in replacement for pprint that's actually pretty" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pycodestyle" version = "2.6.0" @@ -502,6 +570,57 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "pytest" +version = "6.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +checkqa_mypy = ["mypy (==0.780)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "2.10.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-icdiff" +version = "0.5" +description = "use icdiff for better error messages in pytest assertions" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +icdiff = "*" +pprintpp = "*" +pytest = "*" + [[package]] name = "python-dateutil" version = "2.8.1" @@ -523,7 +642,7 @@ python-versions = "*" [[package]] name = "pytz" -version = "2020.1" +version = "2020.4" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -539,7 +658,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "regex" -version = "2020.10.23" +version = "2020.10.28" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -643,11 +762,11 @@ python-versions = "*" [[package]] name = "toml" -version = "0.10.1" +version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typed-ast" @@ -697,7 +816,7 @@ testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake [metadata] lock-version = "1.1" python-versions = "^3.6" -content-hash = "7828680e4b89e2c44cc4de9df25e8c1efd033d4ea9809ab0c5937d1861f1bb86" +content-hash = "8ce6140731b2c2e9d2ba4f591ee85ca6d805851acba95169562ba1311a252ac5" [metadata.files] aniso8601 = [ @@ -712,9 +831,13 @@ appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] attrs = [ - {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, - {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] bandit = [ {file = "bandit-1.6.2-py2.py3-none-any.whl", hash = "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952"}, @@ -725,8 +848,8 @@ black = [ {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, + {file = "certifi-2020.11.8-py2.py3-none-any.whl", hash = "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd"}, + {file = "certifi-2020.11.8.tar.gz", hash = "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, @@ -740,6 +863,42 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] dparse = [ {file = "dparse-0.5.1-py3-none-any.whl", hash = "sha256:e953a25e44ebb60a5c6efc2add4420c177f1d8404509da88da9729202f306994"}, {file = "dparse-0.5.1.tar.gz", hash = "sha256:a1b5f169102e1c894f9a7d5ccf6f9402a836a5d24be80a986c7ce9eaed78f367"}, @@ -812,6 +971,9 @@ gitpython = [ {file = "GitPython-3.1.11-py3-none-any.whl", hash = "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b"}, {file = "GitPython-3.1.11.tar.gz", hash = "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"}, ] +icdiff = [ + {file = "icdiff-1.9.1.tar.gz", hash = "sha256:66972dd03318da55280991db375d3ef6b66d948c67af96c1ebdb21587e86655e"}, +] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, @@ -820,6 +982,10 @@ importlib-metadata = [ {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] itsdangerous = [ {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, @@ -872,8 +1038,8 @@ packaging = [ {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] pathspec = [ - {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, - {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] pbr = [ {file = "pbr-5.5.1-py2.py3-none-any.whl", hash = "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"}, @@ -887,6 +1053,18 @@ pip-tools = [ {file = "pip-tools-5.3.1.tar.gz", hash = "sha256:5672c2b6ca0f1fd803f3b45568c2cf7fadf135b4971e7d665232b2075544c0ef"}, {file = "pip_tools-5.3.1-py2.py3-none-any.whl", hash = "sha256:73787e23269bf8a9230f376c351297b9037ed0d32ab0f9bef4a187d976acc054"}, ] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +pprintpp = [ + {file = "pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d"}, + {file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] pycodestyle = [ {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, @@ -903,6 +1081,17 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] +pytest = [ + {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, + {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, +] +pytest-cov = [ + {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, + {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, +] +pytest-icdiff = [ + {file = "pytest-icdiff-0.5.tar.gz", hash = "sha256:3a14097f4385665cb04330e6ae09a3dd430375f717e94482af6944470ad5f100"}, +] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -911,8 +1100,8 @@ python-icat = [ {file = "python-icat-0.17.0.tar.gz", hash = "sha256:92942ce5e4b4c7b7db8179b78c07c58b56091a1d275385f69dd99d19a58a9396"}, ] pytz = [ - {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, - {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, + {file = "pytz-2020.4-py2.py3-none-any.whl", hash = "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"}, + {file = "pytz-2020.4.tar.gz", hash = "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268"}, ] pyyaml = [ {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, @@ -928,33 +1117,49 @@ pyyaml = [ {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, ] regex = [ - {file = "regex-2020.10.23-cp27-cp27m-win32.whl", hash = "sha256:781906e45ef1d10a0ed9ec8ab83a09b5e0d742de70e627b20d61ccb1b1d3964d"}, - {file = "regex-2020.10.23-cp27-cp27m-win_amd64.whl", hash = "sha256:8cd0d587aaac74194ad3e68029124c06245acaeddaae14cb45844e5c9bebeea4"}, - {file = "regex-2020.10.23-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:af360e62a9790e0a96bc9ac845d87bfa0e4ee0ee68547ae8b5a9c1030517dbef"}, - {file = "regex-2020.10.23-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e21340c07090ddc8c16deebfd82eb9c9e1ec5e62f57bb86194a2595fd7b46e0"}, - {file = "regex-2020.10.23-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:e5f6aa56dda92472e9d6f7b1e6331f4e2d51a67caafff4d4c5121cadac03941e"}, - {file = "regex-2020.10.23-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:c30d8766a055c22e39dd7e1a4f98f6266169f2de05db737efe509c2fb9c8a3c8"}, - {file = "regex-2020.10.23-cp36-cp36m-win32.whl", hash = "sha256:1a065e7a6a1b4aa851a0efa1a2579eabc765246b8b3a5fd74000aaa3134b8b4e"}, - {file = "regex-2020.10.23-cp36-cp36m-win_amd64.whl", hash = "sha256:c95d514093b80e5309bdca5dd99e51bcf82c44043b57c34594d9d7556bd04d05"}, - {file = "regex-2020.10.23-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f4b1c65ee86bfbf7d0c3dfd90592a9e3d6e9ecd36c367c884094c050d4c35d04"}, - {file = "regex-2020.10.23-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d62205f00f461fe8b24ade07499454a3b7adf3def1225e258b994e2215fd15c5"}, - {file = "regex-2020.10.23-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b706c70070eea03411b1761fff3a2675da28d042a1ab7d0863b3efe1faa125c9"}, - {file = "regex-2020.10.23-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d43cf21df524283daa80ecad551c306b7f52881c8d0fe4e3e76a96b626b6d8d8"}, - {file = "regex-2020.10.23-cp37-cp37m-win32.whl", hash = "sha256:570e916a44a361d4e85f355aacd90e9113319c78ce3c2d098d2ddf9631b34505"}, - {file = "regex-2020.10.23-cp37-cp37m-win_amd64.whl", hash = "sha256:1c447b0d108cddc69036b1b3910fac159f2b51fdeec7f13872e059b7bc932be1"}, - {file = "regex-2020.10.23-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8469377a437dbc31e480993399fd1fd15fe26f382dc04c51c9cb73e42965cc06"}, - {file = "regex-2020.10.23-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:59d5c6302d22c16d59611a9fd53556554010db1d47e9df5df37be05007bebe75"}, - {file = "regex-2020.10.23-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a973d5a7a324e2a5230ad7c43f5e1383cac51ef4903bf274936a5634b724b531"}, - {file = "regex-2020.10.23-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:97a023f97cddf00831ba04886d1596ef10f59b93df7f855856f037190936e868"}, - {file = "regex-2020.10.23-cp38-cp38-win32.whl", hash = "sha256:e289a857dca3b35d3615c3a6a438622e20d1bf0abcb82c57d866c8d0be3f44c4"}, - {file = "regex-2020.10.23-cp38-cp38-win_amd64.whl", hash = "sha256:0cb23ed0e327c18fb7eac61ebbb3180ebafed5b9b86ca2e15438201e5903b5dd"}, - {file = "regex-2020.10.23-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c53dc8ee3bb7b7e28ee9feb996a0c999137be6c1d3b02cb6b3c4cba4f9e5ed09"}, - {file = "regex-2020.10.23-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6a46eba253cedcbe8a6469f881f014f0a98819d99d341461630885139850e281"}, - {file = "regex-2020.10.23-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:240509721a663836b611fa13ca1843079fc52d0b91ef3f92d9bba8da12e768a0"}, - {file = "regex-2020.10.23-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f567df0601e9c7434958143aebea47a9c4b45434ea0ae0286a4ec19e9877169"}, - {file = "regex-2020.10.23-cp39-cp39-win32.whl", hash = "sha256:bfd7a9fddd11d116a58b62ee6c502fd24cfe22a4792261f258f886aa41c2a899"}, - {file = "regex-2020.10.23-cp39-cp39-win_amd64.whl", hash = "sha256:1a511470db3aa97432ac8c1bf014fcc6c9fbfd0f4b1313024d342549cf86bcd6"}, - {file = "regex-2020.10.23.tar.gz", hash = "sha256:2278453c6a76280b38855a263198961938108ea2333ee145c5168c36b8e2b376"}, + {file = "regex-2020.10.28-cp27-cp27m-win32.whl", hash = "sha256:4b5a9bcb56cc146c3932c648603b24514447eafa6ce9295234767bf92f69b504"}, + {file = "regex-2020.10.28-cp27-cp27m-win_amd64.whl", hash = "sha256:c13d311a4c4a8d671f5860317eb5f09591fbe8259676b86a85769423b544451e"}, + {file = "regex-2020.10.28-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c454ad88e56e80e44f824ef8366bb7e4c3def12999151fd5c0ea76a18fe9aa3e"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c8a2b7ccff330ae4c460aff36626f911f918555660cc28163417cb84ffb25789"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4afa350f162551cf402bfa3cd8302165c8e03e689c897d185f16a167328cc6dd"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b88fa3b8a3469f22b4f13d045d9bd3eda797aa4e406fde0a2644bc92bbdd4bdd"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f43109822df2d3faac7aad79613f5f02e4eab0fc8ad7932d2e70e2a83bd49c26"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:de7fd57765398d141949946c84f3590a68cf5887dac3fc52388df0639b01eda4"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:9b6305295b6591e45f069d3553c54d50cc47629eb5c218aac99e0f7fafbf90a1"}, + {file = "regex-2020.10.28-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:bd904c0dec29bbd0769887a816657491721d5f545c29e30fd9d7a1a275dc80ab"}, + {file = "regex-2020.10.28-cp36-cp36m-win32.whl", hash = "sha256:8092a5a06ad9a7a247f2a76ace121183dc4e1a84c259cf9c2ce3bbb69fac3582"}, + {file = "regex-2020.10.28-cp36-cp36m-win_amd64.whl", hash = "sha256:49461446b783945597c4076aea3f49aee4b4ce922bd241e4fcf62a3e7c61794c"}, + {file = "regex-2020.10.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:297116e79074ec2a2f885d22db00ce6e88b15f75162c5e8b38f66ea734e73c64"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8ca9dca965bd86ea3631b975d63b0693566d3cc347e55786d5514988b6f5b84c"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea37320877d56a7f0a1e6a625d892cf963aa7f570013499f5b8d5ab8402b5625"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:3a5f08039eee9ea195a89e180c5762bfb55258bfb9abb61a20d3abee3b37fd12"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:cb905f3d2e290a8b8f1579d3984f2cfa7c3a29cc7cba608540ceeed18513f520"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:96f99219dddb33e235a37283306834700b63170d7bb2a1ee17e41c6d589c8eb9"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:227a8d2e5282c2b8346e7f68aa759e0331a0b4a890b55a5cfbb28bd0261b84c0"}, + {file = "regex-2020.10.28-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:2564def9ce0710d510b1fc7e5178ce2d20f75571f788b5197b3c8134c366f50c"}, + {file = "regex-2020.10.28-cp37-cp37m-win32.whl", hash = "sha256:a62162be05edf64f819925ea88d09d18b09bebf20971b363ce0c24e8b4aa14c0"}, + {file = "regex-2020.10.28-cp37-cp37m-win_amd64.whl", hash = "sha256:03855ee22980c3e4863dc84c42d6d2901133362db5daf4c36b710dd895d78f0a"}, + {file = "regex-2020.10.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf4f896c42c63d1f22039ad57de2644c72587756c0cfb3cc3b7530cfe228277f"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux1_i686.whl", hash = "sha256:625116aca6c4b57c56ea3d70369cacc4d62fead4930f8329d242e4fe7a58ce4b"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dc522e25e57e88b4980d2bdd334825dbf6fa55f28a922fc3bfa60cc09e5ef53"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:119e0355dbdd4cf593b17f2fc5dbd4aec2b8899d0057e4957ba92f941f704bf5"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:cfcf28ed4ce9ced47b9b9670a4f0d3d3c0e4d4779ad4dadb1ad468b097f808aa"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b45bab9f224de276b7bc916f6306b86283f6aa8afe7ed4133423efb42015a898"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:52e83a5f28acd621ba8e71c2b816f6541af7144b69cc5859d17da76c436a5427"}, + {file = "regex-2020.10.28-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:aacc8623ffe7999a97935eeabbd24b1ae701d08ea8f874a6ff050e93c3e658cf"}, + {file = "regex-2020.10.28-cp38-cp38-win32.whl", hash = "sha256:06b52815d4ad38d6524666e0d50fe9173533c9cc145a5779b89733284e6f688f"}, + {file = "regex-2020.10.28-cp38-cp38-win_amd64.whl", hash = "sha256:c3466a84fce42c2016113101018a9981804097bacbab029c2d5b4fcb224b89de"}, + {file = "regex-2020.10.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:127a9e0c0d91af572fbb9e56d00a504dbd4c65e574ddda3d45b55722462210de"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c2c6c56ee97485a127555c9595c069201b5161de9d05495fbe2132b5ac104786"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1ec66700a10e3c75f1f92cbde36cca0d3aaee4c73dfa26699495a3a30b09093c"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:11116d424734fe356d8777f89d625f0df783251ada95d6261b4c36ad27a394bb"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f1fce1e4929157b2afeb4bb7069204d4370bab9f4fc03ca1fbec8bd601f8c87d"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3dfca201fa6b326239e1bccb00b915e058707028809b8ecc0cf6819ad233a740"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:b8a686a6c98872007aa41fdbb2e86dc03b287d951ff4a7f1da77fb7f14113e4d"}, + {file = "regex-2020.10.28-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c32c91a0f1ac779cbd73e62430de3d3502bbc45ffe5bb6c376015acfa848144b"}, + {file = "regex-2020.10.28-cp39-cp39-win32.whl", hash = "sha256:832339223b9ce56b7b15168e691ae654d345ac1635eeb367ade9ecfe0e66bee0"}, + {file = "regex-2020.10.28-cp39-cp39-win_amd64.whl", hash = "sha256:654c1635f2313d0843028487db2191530bca45af61ca85d0b16555c399625b0e"}, + {file = "regex-2020.10.28.tar.gz", hash = "sha256:dd3e6547ecf842a29cf25123fbf8d2461c53c8d37aa20d87ecee130c89b7079b"}, ] requests = [ {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, @@ -989,8 +1194,8 @@ text-unidecode = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, diff --git a/pyproject.toml b/pyproject.toml index a9ae4287..5fea5a14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,10 @@ flake8-commas = "^2.0.0" flake8-comprehensions = "^3.3.0" flake8-logging-format = "^0.6.0" pep8-naming = "^0.11.1" +pytest = "^6.1.2" +coverage = {extras = ["toml"], version = "^5.3"} +pytest-cov = "^2.10.1" +pytest-icdiff = "^0.5" [tool.poetry.scripts] datagateway-api = "datagateway_api.src.main:run_api" @@ -43,3 +47,13 @@ datagateway-api = "datagateway_api.src.main:run_api" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.coverage.paths] +source = ["datagateway_api"] + +[tool.coverage.run] +branch = true +source = ["datagateway_api"] + +[tool.coverage.report] +show_missing = true diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..05313099 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta + +from flask import Flask +import pytest + +from datagateway_api.common.database.helpers import ( + delete_row_by_id, + insert_row_into_table, +) +from datagateway_api.common.database.models import SESSION +from datagateway_api.src.main import create_api_endpoints, create_app_infrastructure + + +@pytest.fixture() +def bad_credentials_header(): + return {"Authorization": "Bearer Invalid"} + + +@pytest.fixture() +def invalid_credentials_header(): + return {"Authorization": "Test"} + + +@pytest.fixture(scope="package") +def flask_test_app(): + """This is used to check the endpoints exist and have the correct HTTP methods""" + test_app = Flask(__name__) + api, spec = create_app_infrastructure(test_app) + create_api_endpoints(test_app, api, spec) + + yield test_app + + +@pytest.fixture(scope="package") +def flask_test_app_db(): + db_app = Flask(__name__) + db_app.config["TESTING"] = True + db_app.config["TEST_BACKEND"] = "db" + + api, spec = create_app_infrastructure(db_app) + create_api_endpoints(db_app, api, spec) + + yield db_app.test_client() + + +@pytest.fixture() +def valid_db_credentials_header(): + session = SESSION() + session.ID = "Test" + session.EXPIREDATETIME = datetime.now() + timedelta(hours=1) + session.username = "Test User" + + insert_row_into_table(SESSION, session) + + yield {"Authorization": f"Bearer {session.ID}"} + + delete_row_by_id(SESSION, "Test") diff --git a/test/db/conftest.py b/test/db/conftest.py new file mode 100644 index 00000000..6977104e --- /dev/null +++ b/test/db/conftest.py @@ -0,0 +1,129 @@ +from datetime import datetime +import uuid + +import pytest + +from datagateway_api.common.constants import Constants +from datagateway_api.common.database.helpers import ( + delete_row_by_id, + insert_row_into_table, +) +from datagateway_api.common.database.models import ( + FACILITYCYCLE, + INSTRUMENT, + INVESTIGATION, + INVESTIGATIONINSTRUMENT, +) + + +def set_meta_attributes(entity): + db_meta_attributes = { + "CREATE_TIME": Constants.TEST_MOD_CREATE_DATETIME, + "MOD_TIME": Constants.TEST_MOD_CREATE_DATETIME, + "CREATE_ID": "test create id", + "MOD_ID": "test mod id", + } + + for attr, value in db_meta_attributes.items(): + setattr(entity, attr, value) + + +def create_investigation_db_data(num_entities=1): + test_data = [] + + for i in range(num_entities): + investigation = INVESTIGATION() + investigation.NAME = f"Test Data for DataGateway API Testing (DB) {i}" + investigation.TITLE = f"Title for DataGateway API Testing (DB) {i}" + investigation.STARTDATE = datetime( + year=2020, month=1, day=4, hour=1, minute=1, second=1, + ) + investigation.ENDDATE = datetime( + year=2020, month=1, day=8, hour=1, minute=1, second=1, + ) + investigation.VISIT_ID = str(uuid.uuid1()) + investigation.FACILITY_ID = 1 + investigation.TYPE_ID = 1 + + set_meta_attributes(investigation) + + insert_row_into_table(INVESTIGATION, investigation) + + test_data.append(investigation) + + if len(test_data) == 1: + return test_data[0] + else: + return test_data + + +@pytest.fixture() +def single_investigation_test_data_db(): + investigation = create_investigation_db_data() + + yield investigation + + delete_row_by_id(INVESTIGATION, investigation.ID) + + +@pytest.fixture() +def multiple_investigation_test_data_db(): + investigations = create_investigation_db_data(num_entities=5) + + yield investigations + + for investigation in investigations: + delete_row_by_id(INVESTIGATION, investigation.ID) + + +@pytest.fixture() +def isis_specific_endpoint_data_db(): + facility_cycle = FACILITYCYCLE() + facility_cycle.NAME = "Test cycle for DG API testing (DB)" + facility_cycle.STARTDATE = datetime( + year=2020, month=1, day=1, hour=1, minute=1, second=1, + ) + facility_cycle.ENDDATE = datetime( + year=2020, month=2, day=1, hour=1, minute=1, second=1, + ) + facility_cycle.FACILITY_ID = 1 + set_meta_attributes(facility_cycle) + insert_row_into_table(FACILITYCYCLE, facility_cycle) + + investigation = create_investigation_db_data() + + instrument = INSTRUMENT() + instrument.NAME = "Test Instrument for DataGateway API Endpoint Testing (DB)" + instrument.FACILITY_ID = 1 + set_meta_attributes(instrument) + insert_row_into_table(INSTRUMENT, instrument) + + investigation_instrument = INVESTIGATIONINSTRUMENT() + investigation_instrument.INVESTIGATION_ID = investigation.ID + investigation_instrument.INSTRUMENT_ID = instrument.ID + set_meta_attributes(investigation_instrument) + + insert_row_into_table(INVESTIGATIONINSTRUMENT, investigation_instrument) + + yield (instrument.ID, facility_cycle, investigation) + + delete_row_by_id(INVESTIGATIONINSTRUMENT, investigation_instrument.ID) + delete_row_by_id(FACILITYCYCLE, facility_cycle.ID) + delete_row_by_id(INVESTIGATION, investigation.ID) + delete_row_by_id(INSTRUMENT, instrument.ID) + + +@pytest.fixture() +def final_instrument_id(flask_test_app_db, valid_db_credentials_header): + final_instrument_result = flask_test_app_db.get( + '/instruments/findone?order="ID DESC"', headers=valid_db_credentials_header, + ) + return final_instrument_result.json["ID"] + + +@pytest.fixture() +def final_facilitycycle_id(flask_test_app_db, valid_db_credentials_header): + final_facilitycycle_result = flask_test_app_db.get( + '/facilitycycles/findone?order="ID DESC"', headers=valid_db_credentials_header, + ) + return final_facilitycycle_result.json["ID"] diff --git a/test/db/endpoints/test_count_with_filters_db.py b/test/db/endpoints/test_count_with_filters_db.py new file mode 100644 index 00000000..0dd0795d --- /dev/null +++ b/test/db/endpoints/test_count_with_filters_db.py @@ -0,0 +1,26 @@ +import pytest + + +class TestDBCountWithFilters: + @pytest.mark.usefixtures("single_investigation_test_data_db") + def test_valid_count_with_filters( + self, flask_test_app_db, valid_db_credentials_header, + ): + test_response = flask_test_app_db.get( + '/investigations/count?where={"TITLE": {"like": "Title for DataGateway API' + ' Testing (DB)"}}', + headers=valid_db_credentials_header, + ) + + assert test_response.json == 1 + + def test_valid_no_results_count_with_filters( + self, flask_test_app_db, valid_db_credentials_header, + ): + test_response = flask_test_app_db.get( + '/investigations/count?where={"TITLE": {"like": "This filter should cause a' + '404 for testing purposes..."}}', + headers=valid_db_credentials_header, + ) + + assert test_response.json == 0 diff --git a/test/db/endpoints/test_findone_db.py b/test/db/endpoints/test_findone_db.py new file mode 100644 index 00000000..3da73274 --- /dev/null +++ b/test/db/endpoints/test_findone_db.py @@ -0,0 +1,25 @@ +class TestDBFindone: + def test_valid_findone_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + single_investigation_test_data_db, + ): + test_response = flask_test_app_db.get( + '/investigations/findone?where={"TITLE": {"like": "Title for DataGateway' + ' API Testing (DB)"}}', + headers=valid_db_credentials_header, + ) + + assert test_response.json == single_investigation_test_data_db.to_dict() + + def test_valid_no_results_findone_with_filters( + self, flask_test_app_db, valid_db_credentials_header, + ): + test_response = flask_test_app_db.get( + '/investigations/findone?where={"TITLE": {"eq": "This filter should cause a' + '404 for testing purposes..."}}', + headers=valid_db_credentials_header, + ) + + assert test_response.status_code == 404 diff --git a/test/db/endpoints/test_get_by_id_db.py b/test/db/endpoints/test_get_by_id_db.py new file mode 100644 index 00000000..3c2932a7 --- /dev/null +++ b/test/db/endpoints/test_get_by_id_db.py @@ -0,0 +1,37 @@ +class TestDBGetByID: + def test_valid_get_with_id( + self, + flask_test_app_db, + valid_db_credentials_header, + single_investigation_test_data_db, + ): + # Need to identify the ID given to the test data + investigation_data = flask_test_app_db.get( + '/investigations?where={"TITLE": {"like": "Title for DataGateway API' + ' Testing (DB)"}}', + headers=valid_db_credentials_header, + ) + test_data_id = investigation_data.json[0]["ID"] + + test_response = flask_test_app_db.get( + f"/investigations/{test_data_id}", headers=valid_db_credentials_header, + ) + + assert test_response.json == single_investigation_test_data_db.to_dict() + + def test_invalid_get_with_id( + self, flask_test_app_db, valid_db_credentials_header, + ): + final_investigation_result = flask_test_app_db.get( + '/investigations/findone?order="ID DESC"', + headers=valid_db_credentials_header, + ) + test_data_id = final_investigation_result.json["ID"] + + # Adding 100 onto the ID to the most recent result should ensure a 404 + test_response = flask_test_app_db.get( + f"/investigations/{test_data_id + 100}", + headers=valid_db_credentials_header, + ) + + assert test_response.status_code == 404 diff --git a/test/db/endpoints/test_get_with_filters.py b/test/db/endpoints/test_get_with_filters.py new file mode 100644 index 00000000..da25b894 --- /dev/null +++ b/test/db/endpoints/test_get_with_filters.py @@ -0,0 +1,73 @@ +import pytest + + +class TestDBGetWithFilters: + def test_valid_get_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + single_investigation_test_data_db, + ): + test_response = flask_test_app_db.get( + '/investigations?where={"TITLE": {"like": "Title for DataGateway API' + ' Testing (DB)"}}', + headers=valid_db_credentials_header, + ) + + assert test_response.json == [single_investigation_test_data_db.to_dict()] + + def test_valid_no_results_get_with_filters( + self, flask_test_app_db, valid_db_credentials_header, + ): + test_response = flask_test_app_db.get( + '/investigations?where={"TITLE": {"eq": "This filter should cause a 404 for' + 'testing purposes..."}}', + headers=valid_db_credentials_header, + ) + + assert test_response.json == [] + + @pytest.mark.usefixtures("multiple_investigation_test_data_db") + def test_valid_get_with_filters_distinct( + self, flask_test_app_db, valid_db_credentials_header, + ): + test_response = flask_test_app_db.get( + '/investigations?where={"TITLE": {"like": "Title for DataGateway API' + ' Testing (DB)"}}&distinct="TITLE"', + headers=valid_db_credentials_header, + ) + + expected = [ + {"TITLE": f"Title for DataGateway API Testing (DB) {i}"} for i in range(5) + ] + + for title in expected: + assert title in test_response.json + + def test_limit_skip_merge_get_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + multiple_investigation_test_data_db, + ): + skip_value = 1 + limit_value = 2 + + test_response = flask_test_app_db.get( + '/investigations?where={"TITLE": {"like": "Title for DataGateway API' + ' Testing (DB)"}}' + f'&skip={skip_value}&limit={limit_value}&order="ID ASC"', + headers=valid_db_credentials_header, + ) + + # Copy required to ensure data is deleted at the end of the test + investigation_test_data_copy = multiple_investigation_test_data_db.copy() + filtered_investigation_data = [] + filter_count = 0 + while filter_count < limit_value: + filtered_investigation_data.append( + investigation_test_data_copy.pop(skip_value).to_dict(), + ) + filter_count += 1 + + assert test_response.json == filtered_investigation_data diff --git a/test/db/endpoints/test_table_endpoints_db.py b/test/db/endpoints/test_table_endpoints_db.py new file mode 100644 index 00000000..45a95da5 --- /dev/null +++ b/test/db/endpoints/test_table_endpoints_db.py @@ -0,0 +1,109 @@ +class TestDBTableEndpoints: + """ + This class tests the endpoints defined in table_endpoints.py, commonly referred to + as the ISIS specific endpoints + """ + + def test_valid_get_facility_cycles_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + isis_specific_endpoint_data_db, + ): + test_response = flask_test_app_db.get( + f"/instruments/{int(isis_specific_endpoint_data_db[0])}/facilitycycles", + headers=valid_db_credentials_header, + ) + + assert test_response.json == [isis_specific_endpoint_data_db[1].to_dict()] + + def test_invalid_get_facility_cycles_with_filters( + self, flask_test_app_db, valid_db_credentials_header, final_instrument_id, + ): + test_response = flask_test_app_db.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles", + headers=valid_db_credentials_header, + ) + + assert test_response.json == [] + + def test_valid_get_facility_cycles_count_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + isis_specific_endpoint_data_db, + ): + test_response = flask_test_app_db.get( + f"/instruments/{isis_specific_endpoint_data_db[0]}/facilitycycles/count", + headers=valid_db_credentials_header, + ) + + assert test_response.json == 1 + + def test_invalid_get_facility_cycles_count_with_filters( + self, flask_test_app_db, valid_db_credentials_header, final_instrument_id, + ): + test_response = flask_test_app_db.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/count", + headers=valid_db_credentials_header, + ) + + assert test_response.json == 0 + + def test_valid_get_investigations_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + isis_specific_endpoint_data_db, + ): + test_response = flask_test_app_db.get( + f"/instruments/{isis_specific_endpoint_data_db[0]}/facilitycycles/" + f"{isis_specific_endpoint_data_db[1].to_dict()['ID']}/investigations", + headers=valid_db_credentials_header, + ) + + assert test_response.json == [isis_specific_endpoint_data_db[2].to_dict()] + + def test_invalid_get_investigations_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + final_instrument_id, + final_facilitycycle_id, + ): + test_response = flask_test_app_db.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/" + f"{final_facilitycycle_id + 100}/investigations", + headers=valid_db_credentials_header, + ) + + assert test_response.json == [] + + def test_valid_get_investigations_count_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + isis_specific_endpoint_data_db, + ): + test_response = flask_test_app_db.get( + f"/instruments/{isis_specific_endpoint_data_db[0]}/facilitycycles/" + f"{isis_specific_endpoint_data_db[1].to_dict()['ID']}/investigations/count", + headers=valid_db_credentials_header, + ) + + assert test_response.json == 1 + + def test_invalid_get_investigations_count_with_filters( + self, + flask_test_app_db, + valid_db_credentials_header, + final_instrument_id, + final_facilitycycle_id, + ): + test_response = flask_test_app_db.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/" + f"{final_facilitycycle_id + 100}/investigations/count", + headers=valid_db_credentials_header, + ) + + assert test_response.json == 0 diff --git a/test/db/test_entity_helper.py b/test/db/test_entity_helper.py new file mode 100644 index 00000000..bba41776 --- /dev/null +++ b/test/db/test_entity_helper.py @@ -0,0 +1,200 @@ +import pytest + +from datagateway_api.common.constants import Constants +from datagateway_api.common.database.models import ( + DATAFILE, + DATAFILEFORMAT, + DATASET, + INVESTIGATION, +) + + +@pytest.fixture() +def dataset_entity(): + dataset = DATASET() + investigation = INVESTIGATION() + dataset.INVESTIGATION = investigation + + return dataset + + +@pytest.fixture() +def datafile_entity(dataset_entity): + datafileformat = DATAFILEFORMAT() + datafile = DATAFILE() + datafile.ID = 1 + datafile.LOCATION = "test location" + datafile.DATASET = dataset_entity + datafile.DATAFILEFORMAT = datafileformat + datafile.NAME = "test name" + datafile.MOD_TIME = Constants.TEST_MOD_CREATE_DATETIME + datafile.CREATE_TIME = Constants.TEST_MOD_CREATE_DATETIME + datafile.CHECKSUM = "test checksum" + datafile.FILESIZE = 64 + datafile.DATAFILEMODTIME = Constants.TEST_MOD_CREATE_DATETIME + datafile.DATAFILECREATETIME = Constants.TEST_MOD_CREATE_DATETIME + datafile.DATASET_ID = 1 + datafile.DOI = "test doi" + datafile.DESCRIPTION = "test description" + datafile.CREATE_ID = "test create id" + datafile.MOD_ID = "test mod id" + datafile.DATAFILEFORMAT_ID = 1 + + return datafile + + +class TestEntityHelper: + def test_valid_to_dict(self, datafile_entity): + expected_dict = { + "ID": 1, + "LOCATION": "test location", + "NAME": "test name", + "MOD_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "CHECKSUM": "test checksum", + "FILESIZE": 64, + "DATAFILEMODTIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATAFILECREATETIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET_ID": 1, + "DOI": "test doi", + "DESCRIPTION": "test description", + "CREATE_ID": "test create id", + "MOD_ID": "test mod id", + "DATAFILEFORMAT_ID": 1, + "CREATE_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + } + + test_data = datafile_entity.to_dict() + + assert expected_dict == test_data + + @pytest.mark.parametrize( + "expected_dict, entity_names", + [ + pytest.param( + { + "ID": 1, + "LOCATION": "test location", + "NAME": "test name", + "MOD_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "CHECKSUM": "test checksum", + "FILESIZE": 64, + "DATAFILEMODTIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATAFILECREATETIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET_ID": 1, + "DOI": "test doi", + "DESCRIPTION": "test description", + "CREATE_ID": "test create id", + "MOD_ID": "test mod id", + "DATAFILEFORMAT_ID": 1, + "CREATE_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET": { + "ID": None, + "CREATE_TIME": None, + "MOD_TIME": None, + "CREATE_ID": None, + "MOD_ID": None, + "INVESTIGATION_ID": None, + "COMPLETE": None, + "DESCRIPTION": None, + "DOI": None, + "END_DATE": None, + "LOCATION": None, + "NAME": None, + "STARTDATE": None, + "SAMPLE_ID": None, + "TYPE_ID": None, + }, + }, + "DATASET", + id="Dataset", + ), + pytest.param( + { + "ID": 1, + "LOCATION": "test location", + "NAME": "test name", + "MOD_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "CHECKSUM": "test checksum", + "FILESIZE": 64, + "DATAFILEMODTIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATAFILECREATETIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET_ID": 1, + "DOI": "test doi", + "DESCRIPTION": "test description", + "CREATE_ID": "test create id", + "MOD_ID": "test mod id", + "DATAFILEFORMAT_ID": 1, + "CREATE_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET": { + "ID": None, + "CREATE_TIME": None, + "MOD_TIME": None, + "CREATE_ID": None, + "MOD_ID": None, + "INVESTIGATION_ID": None, + "COMPLETE": None, + "DESCRIPTION": None, + "DOI": None, + "END_DATE": None, + "LOCATION": None, + "NAME": None, + "STARTDATE": None, + "SAMPLE_ID": None, + "TYPE_ID": None, + "INVESTIGATION": { + "ID": None, + "CREATE_ID": None, + "CREATE_TIME": None, + "DOI": None, + "ENDDATE": None, + "MOD_ID": None, + "MOD_TIME": None, + "NAME": None, + "RELEASEDATE": None, + "STARTDATE": None, + "SUMMARY": None, + "TITLE": None, + "VISIT_ID": None, + "FACILITY_ID": None, + "TYPE_ID": None, + }, + }, + }, + {"DATASET": "INVESTIGATION"}, + id="Dataset including investigation", + ), + ], + ) + def test_valid_to_nested_dict(self, datafile_entity, expected_dict, entity_names): + test_data = datafile_entity.to_nested_dict(entity_names) + + assert expected_dict == test_data + + def test_valid_get_related_entity(self, dataset_entity, datafile_entity): + assert dataset_entity == datafile_entity.get_related_entity("DATASET") + + def test_valid_update_from_dict(self, datafile_entity): + datafile = DATAFILE() + test_dict_data = { + "ID": 1, + "LOCATION": "test location", + "NAME": "test name", + "MOD_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "CHECKSUM": "test checksum", + "FILESIZE": 64, + "DATAFILEMODTIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATAFILECREATETIME": str(Constants.TEST_MOD_CREATE_DATETIME), + "DATASET_ID": 1, + "DOI": "test doi", + "DESCRIPTION": "test description", + "CREATE_ID": "test create id", + "MOD_ID": "test mod id", + "DATAFILEFORMAT_ID": 1, + "CREATE_TIME": str(Constants.TEST_MOD_CREATE_DATETIME), + } + + datafile.update_from_dict(test_dict_data) + + expected_datafile_dict = datafile_entity.to_dict() + + assert test_dict_data == expected_datafile_dict diff --git a/test/db/test_query_filter_factory.py b/test/db/test_query_filter_factory.py new file mode 100644 index 00000000..f5d3c682 --- /dev/null +++ b/test/db/test_query_filter_factory.py @@ -0,0 +1,74 @@ +import pytest + +from datagateway_api.common.database.filters import ( + DatabaseDistinctFieldFilter, + DatabaseIncludeFilter, + DatabaseLimitFilter, + DatabaseOrderFilter, + DatabaseSkipFilter, + DatabaseWhereFilter, +) +from datagateway_api.common.query_filter_factory import QueryFilterFactory + + +class TestQueryFilterFactory: + @pytest.mark.usefixtures("flask_test_app_db") + def test_valid_distinct_filter(self): + assert isinstance( + QueryFilterFactory.get_query_filter({"distinct": "TEST"}), + DatabaseDistinctFieldFilter, + ) + + @pytest.mark.usefixtures("flask_test_app_db") + @pytest.mark.parametrize( + "filter_input", + [ + pytest.param({"include": "DATAFILE"}, id="string"), + pytest.param({"include": ["TEST"]}, id="list of strings inside dictionary"), + pytest.param( + {"include": {"Test": ["TEST1", "Test2"]}}, + id="list of strings inside nested dictionary", + ), + ], + ) + def test_valid_include_filter(self, filter_input): + assert isinstance( + QueryFilterFactory.get_query_filter(filter_input), DatabaseIncludeFilter, + ) + + @pytest.mark.usefixtures("flask_test_app_db") + def test_valid_limit_filter(self): + assert isinstance( + QueryFilterFactory.get_query_filter({"limit": 10}), DatabaseLimitFilter, + ) + + @pytest.mark.usefixtures("flask_test_app_db") + def test_valid_order_filter(self): + assert isinstance( + QueryFilterFactory.get_query_filter({"order": "ID DESC"}), + DatabaseOrderFilter, + ) + + @pytest.mark.usefixtures("flask_test_app_db") + def test_valid_skip_filter(self): + assert isinstance( + QueryFilterFactory.get_query_filter({"skip": 10}), DatabaseSkipFilter, + ) + + @pytest.mark.usefixtures("flask_test_app_db") + @pytest.mark.parametrize( + "filter_input", + [ + pytest.param({"where": {"ID": {"eq": "1"}}}, id="eq operator"), + pytest.param({"where": {"ID": {"gt": "1"}}}, id="gt operator"), + pytest.param({"where": {"ID": {"gte": "1"}}}, id="gte operator"), + pytest.param({"where": {"ID": {"in": ["1", "2", "3"]}}}, id="in operator"), + pytest.param({"where": {"ID": {"like": "3"}}}, id="like operator"), + pytest.param({"where": {"ID": {"lt": "1"}}}, id="lt operator"), + pytest.param({"where": {"ID": {"lte": "1"}}}, id="lte operator"), + ], + ) + def test_valid_where_filter(self, filter_input): + assert isinstance( + QueryFilterFactory.get_query_filter(filter_input), DatabaseWhereFilter, + ) diff --git a/test/db/test_requires_session_id.py b/test/db/test_requires_session_id.py new file mode 100644 index 00000000..4ebf43a7 --- /dev/null +++ b/test/db/test_requires_session_id.py @@ -0,0 +1,31 @@ +class TestRequiresSessionID: + """ + This class tests the session decorator used for the database backend. The equivalent + decorator for the Python ICAT backend is tested in `test_session_handling.py` + """ + + def test_invalid_missing_credentials(self, flask_test_app_db): + test_response = flask_test_app_db.get("/datafiles") + + assert test_response.status_code == 401 + + def test_invalid_credentials(self, flask_test_app_db, invalid_credentials_header): + test_response = flask_test_app_db.get( + "/datafiles", headers=invalid_credentials_header, + ) + + assert test_response.status_code == 403 + + def test_bad_credentials(self, flask_test_app_db, bad_credentials_header): + test_response = flask_test_app_db.get( + "/datafiles", headers=bad_credentials_header, + ) + + assert test_response.status_code == 403 + + def test_valid_credentials(self, flask_test_app_db, valid_db_credentials_header): + test_response = flask_test_app_db.get( + "/datafiles?limit=0", headers=valid_db_credentials_header, + ) + + assert test_response.status_code == 200 diff --git a/test/icat/conftest.py b/test/icat/conftest.py new file mode 100644 index 00000000..2549dd83 --- /dev/null +++ b/test/icat/conftest.py @@ -0,0 +1,153 @@ +from datetime import datetime +import uuid + +from flask import Flask +from icat.client import Client +from icat.exception import ICATNoObjectError +from icat.query import Query +import pytest + +from datagateway_api.common.config import config +from datagateway_api.src.main import create_api_endpoints, create_app_infrastructure +from test.icat.test_query import prepare_icat_data_for_assertion + + +@pytest.fixture(scope="package") +def icat_client(): + client = Client(config.get_icat_url(), checkCert=config.get_icat_check_cert()) + client.login(config.get_test_mechanism(), config.get_test_user_credentials()) + return client + + +@pytest.fixture() +def valid_icat_credentials_header(icat_client): + return {"Authorization": f"Bearer {icat_client.sessionId}"} + + +@pytest.fixture() +def icat_query(icat_client): + return Query(icat_client, "Investigation") + + +def create_investigation_test_data(client, num_entities=1): + test_data = [] + + for i in range(num_entities): + investigation = client.new("investigation") + investigation.name = f"Test Data for DataGateway API Testing {i}" + investigation.title = ( + f"Test data for the Python ICAT Backend on DataGateway API {i}" + ) + investigation.startDate = datetime( + year=2020, month=1, day=4, hour=1, minute=1, second=1, + ) + investigation.endDate = datetime( + year=2020, month=1, day=8, hour=1, minute=1, second=1, + ) + # UUID visit ID means uniquesness constraint should always be met + investigation.visitId = str(uuid.uuid1()) + investigation.facility = client.get("Facility", 1) + investigation.type = client.get("InvestigationType", 1) + investigation.create() + + test_data.append(investigation) + + if len(test_data) == 1: + return test_data[0] + else: + return test_data + + +@pytest.fixture() +def single_investigation_test_data(icat_client): + investigation = create_investigation_test_data(icat_client) + investigation_dict = prepare_icat_data_for_assertion([investigation]) + + yield investigation_dict + + # Remove data from ICAT + try: + icat_client.delete(investigation) + except ICATNoObjectError as e: + # This should occur on DELETE endpoints, normal behaviour for those tests + print(e) + + +@pytest.fixture() +def multiple_investigation_test_data(icat_client): + investigation_dicts = [] + investigations = create_investigation_test_data(icat_client, num_entities=5) + investigation_dicts = prepare_icat_data_for_assertion(investigations) + + yield investigation_dicts + + for investigation in investigations: + icat_client.delete(investigation) + + +@pytest.fixture(scope="package") +def flask_test_app_icat(flask_test_app): + icat_app = Flask(__name__) + icat_app.config["TESTING"] = True + icat_app.config["TEST_BACKEND"] = "python_icat" + + api, spec = create_app_infrastructure(icat_app) + create_api_endpoints(icat_app, api, spec) + + yield icat_app.test_client() + + +@pytest.fixture() +def isis_specific_endpoint_data(icat_client): + facility_cycle = icat_client.new("facilityCycle") + facility_cycle.name = "Test cycle for DataGateway API testing" + facility_cycle.startDate = datetime( + year=2020, month=1, day=1, hour=1, minute=1, second=1, + ) + facility_cycle.endDate = datetime( + year=2020, month=2, day=1, hour=1, minute=1, second=1, + ) + facility_cycle.facility = icat_client.get("Facility", 1) + facility_cycle.create() + + investigation = create_investigation_test_data(icat_client) + investigation_dict = prepare_icat_data_for_assertion([investigation]) + + instrument = icat_client.new("instrument") + instrument.name = "Test Instrument for DataGateway API Endpoint Testing" + instrument.facility = icat_client.get("Facility", 1) + instrument.create() + + investigation_instrument = icat_client.new("investigationInstrument") + investigation_instrument.investigation = investigation + investigation_instrument.instrument = instrument + investigation_instrument.create() + + facility_cycle_dict = prepare_icat_data_for_assertion([facility_cycle]) + + yield (instrument.id, facility_cycle_dict, facility_cycle.id, investigation_dict) + + try: + # investigation_instrument removed when deleting the objects its related objects + icat_client.delete(facility_cycle) + icat_client.delete(investigation) + icat_client.delete(instrument) + except ICATNoObjectError as e: + print(e) + + +@pytest.fixture() +def final_instrument_id(flask_test_app_icat, valid_icat_credentials_header): + final_instrument_result = flask_test_app_icat.get( + '/instruments/findone?order="id DESC"', headers=valid_icat_credentials_header, + ) + return final_instrument_result.json["id"] + + +@pytest.fixture() +def final_facilitycycle_id(flask_test_app_icat, valid_icat_credentials_header): + final_facilitycycle_result = flask_test_app_icat.get( + '/facilitycycles/findone?order="id DESC"', + headers=valid_icat_credentials_header, + ) + return final_facilitycycle_result.json["id"] diff --git a/test/icat/endpoints/test_count_with_filters_icat.py b/test/icat/endpoints/test_count_with_filters_icat.py new file mode 100644 index 00000000..3f4d09c1 --- /dev/null +++ b/test/icat/endpoints/test_count_with_filters_icat.py @@ -0,0 +1,26 @@ +import pytest + + +class TestICATCountWithFilters: + @pytest.mark.usefixtures("single_investigation_test_data") + def test_valid_count_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + test_response = flask_test_app_icat.get( + '/investigations/count?where={"title": {"like": "Test data for the Python' + ' ICAT Backend on DataGateway API"}}', + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 1 + + def test_valid_no_results_count_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + test_response = flask_test_app_icat.get( + '/investigations/count?where={"title": {"like": "This filter should cause a' + '404 for testing purposes..."}}', + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 0 diff --git a/test/icat/endpoints/test_create_icat.py b/test/icat/endpoints/test_create_icat.py new file mode 100644 index 00000000..79e4e198 --- /dev/null +++ b/test/icat/endpoints/test_create_icat.py @@ -0,0 +1,131 @@ +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestICATCreateData: + def test_valid_create_data( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + create_investigations_json = [ + { + "name": f"Test Data for API Testing, Data Creation {i}", + "title": "Test data for the Python ICAT Backend on DataGateway API", + "summary": "Test data for DataGateway API testing", + "releaseDate": "2020-03-03 08:00:08", + "startDate": "2020-02-02 09:00:09", + "endDate": "2020-02-03 10:00:10", + "visitId": "Data Creation Visit", + "doi": "DataGateway API Test DOI", + "facility": 1, + "type": 1, + } + for i in range(2) + ] + + test_response = flask_test_app_icat.post( + "/investigations", + headers=valid_icat_credentials_header, + json=create_investigations_json, + ) + + test_data_ids = [] + for investigation_request, investigation_response in zip( + create_investigations_json, test_response.json, + ): + investigation_request.pop("facility") + investigation_request.pop("type") + test_data_ids.append(investigation_response["id"]) + + response_json = prepare_icat_data_for_assertion( + test_response.json, remove_id=True, + ) + + assert create_investigations_json == response_json + + # Delete the entities created by this test + for investigation_id in test_data_ids: + flask_test_app_icat.delete( + f"/investigations/{investigation_id}", + headers=valid_icat_credentials_header, + ) + + def test_valid_boundary_create_data( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + """Create a single investigation, as opposed to multiple""" + + create_investigation_json = { + "name": "Test Data for API Testing, Data Creation 0", + "title": "Test data for the Python ICAT Backend on the API", + "summary": "Test data for DataGateway API testing", + "releaseDate": "2020-03-03 08:00:08", + "startDate": "2020-02-02 09:00:09", + "endDate": "2020-02-03 10:00:10", + "visitId": "Data Creation Visit", + "doi": "DataGateway API Test DOI", + "facility": 1, + "type": 1, + } + + test_response = flask_test_app_icat.post( + "/investigations", + headers=valid_icat_credentials_header, + json=create_investigation_json, + ) + + create_investigation_json.pop("facility") + create_investigation_json.pop("type") + created_test_data_id = test_response.json[0]["id"] + + response_json = prepare_icat_data_for_assertion( + test_response.json, remove_id=True, + ) + + assert [create_investigation_json] == response_json + + flask_test_app_icat.delete( + f"/investigations/{created_test_data_id}", + headers=valid_icat_credentials_header, + ) + + def test_invalid_create_data( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + """An investigation requires a minimum of: name, visitId, facility, type""" + + invalid_request_body = { + "title": "Test Title for DataGateway API Backend testing", + } + + test_response = flask_test_app_icat.post( + "/investigations", + headers=valid_icat_credentials_header, + json=invalid_request_body, + ) + + assert test_response.status_code == 400 + + def test_invalid_existing_data_create( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + """This test targets raising ICATObjectExistsError, causing a 400""" + + # entity.as_dict() removes details about facility and type, hence they're + # hardcoded here instead of using sinle_investigation_test_data + existing_object_json = { + "name": single_investigation_test_data[0]["name"], + "title": single_investigation_test_data[0]["title"], + "visitId": single_investigation_test_data[0]["visitId"], + "facility": 1, + "type": 1, + } + + test_response = flask_test_app_icat.post( + "/investigations", + headers=valid_icat_credentials_header, + json=existing_object_json, + ) + + assert test_response.status_code == 400 diff --git a/test/icat/endpoints/test_delete_by_id_icat.py b/test/icat/endpoints/test_delete_by_id_icat.py new file mode 100644 index 00000000..942fe0bb --- /dev/null +++ b/test/icat/endpoints/test_delete_by_id_icat.py @@ -0,0 +1,32 @@ +class TestDeleteByID: + def test_valid_delete_with_id( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + test_response = flask_test_app_icat.delete( + f'/investigations/{single_investigation_test_data[0]["id"]}', + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 204 + + def test_invalid_delete_with_id( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + """Request with a non-existent ID""" + + final_investigation_result = flask_test_app_icat.get( + '/investigations/findone?order="id DESC"', + headers=valid_icat_credentials_header, + ) + test_data_id = final_investigation_result.json["id"] + + # Adding 100 onto the ID to the most recent result should ensure a 404 + test_response = flask_test_app_icat.delete( + f"/investigations/{test_data_id + 100}", + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 diff --git a/test/icat/endpoints/test_findone_icat.py b/test/icat/endpoints/test_findone_icat.py new file mode 100644 index 00000000..778c7ebb --- /dev/null +++ b/test/icat/endpoints/test_findone_icat.py @@ -0,0 +1,29 @@ +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestICATFindone: + def test_valid_findone_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + test_response = flask_test_app_icat.get( + '/investigations/findone?where={"title": {"like": "Test data for the Python' + ' ICAT Backend on DataGateway API"}}', + headers=valid_icat_credentials_header, + ) + response_json = prepare_icat_data_for_assertion([test_response.json]) + + assert response_json == single_investigation_test_data + + def test_valid_no_results_findone_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + test_response = flask_test_app_icat.get( + '/investigations/findone?where={"title": {"eq": "This filter should cause a' + '404 for testing purposes..."}}', + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 diff --git a/test/icat/endpoints/test_get_by_id_icat.py b/test/icat/endpoints/test_get_by_id_icat.py new file mode 100644 index 00000000..c57ea838 --- /dev/null +++ b/test/icat/endpoints/test_get_by_id_icat.py @@ -0,0 +1,45 @@ +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestICATGetByID: + def test_valid_get_with_id( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + # Need to identify the ID given to the test data + investigation_data = flask_test_app_icat.get( + '/investigations?where={"title": {"like": "Test data for the Python ICAT' + ' Backend on DataGateway API"}}', + headers=valid_icat_credentials_header, + ) + test_data_id = investigation_data.json[0]["id"] + + test_response = flask_test_app_icat.get( + f"/investigations/{test_data_id}", headers=valid_icat_credentials_header, + ) + # Get with ID gives a dictionary response (only ever one result from that kind + # of request), so list around json is required for the call + response_json = prepare_icat_data_for_assertion([test_response.json]) + + assert response_json == single_investigation_test_data + + def test_invalid_get_with_id( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + """Request with a non-existent ID""" + + final_investigation_result = flask_test_app_icat.get( + '/investigations/findone?order="id DESC"', + headers=valid_icat_credentials_header, + ) + test_data_id = final_investigation_result.json["id"] + + # Adding 100 onto the ID to the most recent result should ensure a 404 + test_response = flask_test_app_icat.get( + f"/investigations/{test_data_id + 100}", + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 diff --git a/test/icat/endpoints/test_get_with_filters_icat.py b/test/icat/endpoints/test_get_with_filters_icat.py new file mode 100644 index 00000000..b05e8160 --- /dev/null +++ b/test/icat/endpoints/test_get_with_filters_icat.py @@ -0,0 +1,76 @@ +import pytest + +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestICATGetWithFilters: + def test_valid_get_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + test_response = flask_test_app_icat.get( + '/investigations?where={"title": {"like": "Test data for the Python ICAT' + ' Backend on DataGateway API"}}', + headers=valid_icat_credentials_header, + ) + response_json = prepare_icat_data_for_assertion(test_response.json) + + assert response_json == single_investigation_test_data + + def test_valid_no_results_get_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + test_response = flask_test_app_icat.get( + '/investigations?where={"title": {"eq": "This filter should cause a 404 for' + 'testing purposes..."}}', + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 + + @pytest.mark.usefixtures("multiple_investigation_test_data") + def test_valid_get_with_filters_distinct( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + test_response = flask_test_app_icat.get( + '/investigations?where={"title": {"like": "Test data for the Python ICAT' + ' Backend on DataGateway API"}}&distinct="title"', + headers=valid_icat_credentials_header, + ) + + expected = [ + {"title": f"Test data for the Python ICAT Backend on DataGateway API {i}"} + for i in range(5) + ] + + for title in expected: + assert title in test_response.json + + def test_limit_skip_merge_get_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + multiple_investigation_test_data, + ): + skip_value = 1 + limit_value = 2 + + test_response = flask_test_app_icat.get( + '/investigations?where={"title": {"like": "Test data for the Python ICAT' + ' Backend on DataGateway API"}}' + f'&skip={skip_value}&limit={limit_value}&order="id ASC"', + headers=valid_icat_credentials_header, + ) + response_json = prepare_icat_data_for_assertion(test_response.json) + + filtered_investigation_data = [] + filter_count = 0 + while filter_count < limit_value: + filtered_investigation_data.append( + multiple_investigation_test_data.pop(skip_value), + ) + filter_count += 1 + + assert response_json == filtered_investigation_data diff --git a/test/icat/endpoints/test_table_endpoints_icat.py b/test/icat/endpoints/test_table_endpoints_icat.py new file mode 100644 index 00000000..a62b3c63 --- /dev/null +++ b/test/icat/endpoints/test_table_endpoints_icat.py @@ -0,0 +1,116 @@ +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestICATableEndpoints: + """ + This class tests the endpoints defined in table_endpoints.py, commonly referred to + as the ISIS specific endpoints + """ + + def test_valid_get_facility_cycles_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + isis_specific_endpoint_data, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{isis_specific_endpoint_data[0]}/facilitycycles", + headers=valid_icat_credentials_header, + ) + + response_json = prepare_icat_data_for_assertion(test_response.json) + + assert response_json == isis_specific_endpoint_data[1] + + def test_invalid_get_facility_cycles_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, final_instrument_id, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles", + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 + + def test_valid_get_facility_cycles_count_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + isis_specific_endpoint_data, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{isis_specific_endpoint_data[0]}/facilitycycles/count", + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 1 + + def test_invalid_get_facility_cycles_count_with_filters( + self, flask_test_app_icat, valid_icat_credentials_header, final_instrument_id, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/count", + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 0 + + def test_valid_get_investigations_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + isis_specific_endpoint_data, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{isis_specific_endpoint_data[0]}/facilitycycles/" + f"{isis_specific_endpoint_data[2]}/investigations", + headers=valid_icat_credentials_header, + ) + + response_json = prepare_icat_data_for_assertion(test_response.json) + + assert response_json == isis_specific_endpoint_data[3] + + def test_invalid_get_investigations_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + final_instrument_id, + final_facilitycycle_id, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/" + f"{final_facilitycycle_id + 100}/investigations", + headers=valid_icat_credentials_header, + ) + + assert test_response.status_code == 404 + + def test_valid_get_investigations_count_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + isis_specific_endpoint_data, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{isis_specific_endpoint_data[0]}/facilitycycles/" + f"{isis_specific_endpoint_data[2]}/investigations/count", + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 1 + + def test_invalid_get_investigations_count_with_filters( + self, + flask_test_app_icat, + valid_icat_credentials_header, + final_instrument_id, + final_facilitycycle_id, + ): + test_response = flask_test_app_icat.get( + f"/instruments/{final_instrument_id + 100}/facilitycycles/" + f"{final_facilitycycle_id + 100}/investigations/count", + headers=valid_icat_credentials_header, + ) + + assert test_response.json == 0 diff --git a/test/icat/endpoints/test_update_by_id_icat.py b/test/icat/endpoints/test_update_by_id_icat.py new file mode 100644 index 00000000..af97f54e --- /dev/null +++ b/test/icat/endpoints/test_update_by_id_icat.py @@ -0,0 +1,45 @@ +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestUpdateByID: + def test_valid_update_with_id( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + update_data_json = { + "doi": "Test Data Identifier", + "summary": "Test Summary", + } + single_investigation_test_data[0].update(update_data_json) + + test_response = flask_test_app_icat.patch( + f"/investigations/{single_investigation_test_data[0]['id']}", + headers=valid_icat_credentials_header, + json=update_data_json, + ) + response_json = prepare_icat_data_for_assertion([test_response.json]) + + assert response_json == single_investigation_test_data + + def test_invalid_update_with_id( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + """This test will attempt to put `icatdb` into an invalid state""" + + # DOI cannot be over 255 characters, which this string is + invalid_update_json = { + "doi": "_" * 256, + } + + test_response = flask_test_app_icat.patch( + f"/investigations/{single_investigation_test_data[0]['id']}", + headers=valid_icat_credentials_header, + json=invalid_update_json, + ) + + assert test_response.status_code == 400 diff --git a/test/icat/endpoints/test_update_multiple_icat.py b/test/icat/endpoints/test_update_multiple_icat.py new file mode 100644 index 00000000..0cd02415 --- /dev/null +++ b/test/icat/endpoints/test_update_multiple_icat.py @@ -0,0 +1,113 @@ +import pytest + +from test.icat.test_query import prepare_icat_data_for_assertion + + +class TestUpdateMultipleEntities: + def test_valid_multiple_update_data( + self, + flask_test_app_icat, + valid_icat_credentials_header, + multiple_investigation_test_data, + ): + expected_doi = "Test Data Identifier" + expected_summary = "Test Summary" + + update_data_list = [] + + for investigation in multiple_investigation_test_data: + investigation["doi"] = expected_doi + investigation["summary"] = expected_summary + + update_entity = { + "id": investigation["id"], + "doi": expected_doi, + "summary": expected_summary, + } + update_data_list.append(update_entity) + + test_response = flask_test_app_icat.patch( + "/investigations", + headers=valid_icat_credentials_header, + json=update_data_list, + ) + response_json = prepare_icat_data_for_assertion(test_response.json) + + assert response_json == multiple_investigation_test_data + + def test_valid_boundary_update_data( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + """ Request body is a dictionary, not a list of dictionaries""" + + expected_doi = "Test Data Identifier" + expected_summary = "Test Summary" + + update_data_json = { + "id": single_investigation_test_data[0]["id"], + "doi": expected_doi, + "summary": expected_summary, + } + single_investigation_test_data[0]["doi"] = expected_doi + single_investigation_test_data[0]["summary"] = expected_summary + + test_response = flask_test_app_icat.patch( + "/investigations", + headers=valid_icat_credentials_header, + json=update_data_json, + ) + response_json = prepare_icat_data_for_assertion(test_response.json) + + assert response_json == single_investigation_test_data + + def test_invalid_missing_update_data( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + ): + """There should be an ID in the request body to know which entity to update""" + + update_data_json = { + "doi": "Test Data Identifier", + "summary": "Test Summary", + } + + test_response = flask_test_app_icat.patch( + "/investigations", + headers=valid_icat_credentials_header, + json=update_data_json, + ) + + assert test_response.status_code == 400 + + @pytest.mark.parametrize( + "update_key, update_value", + [ + pytest.param("invalidAttr", "Some Value", id="invalid attribute"), + pytest.param("modId", "simple/root", id="meta attribute update"), + ], + ) + def test_invalid_attribute_update( + self, + flask_test_app_icat, + valid_icat_credentials_header, + single_investigation_test_data, + update_key, + update_value, + ): + invalid_update_data_json = { + "id": single_investigation_test_data[0]["id"], + update_key: update_value, + } + + test_response = flask_test_app_icat.patch( + "/investigations", + headers=valid_icat_credentials_header, + json=invalid_update_data_json, + ) + + assert test_response.status_code == 400 diff --git a/test/icat/filters/test_distinct_filter.py b/test/icat/filters/test_distinct_filter.py new file mode 100644 index 00000000..66255b3a --- /dev/null +++ b/test/icat/filters/test_distinct_filter.py @@ -0,0 +1,45 @@ +import pytest + +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.icat.filters import PythonICATDistinctFieldFilter + + +class TestICATDistinctFilter: + def test_valid_str_field_input(self, icat_query): + test_filter = PythonICATDistinctFieldFilter("name") + test_filter.apply_filter(icat_query) + + assert ( + icat_query.conditions == {"name": "!= null"} + and icat_query.aggregate == "DISTINCT" + ) + + def test_valid_list_fields_input(self, icat_query): + test_filter = PythonICATDistinctFieldFilter(["doi", "name", "title"]) + test_filter.apply_filter(icat_query) + + assert ( + icat_query.conditions + == {"doi": "!= null", "name": "!= null", "title": "!= null"} + and icat_query.aggregate == "DISTINCT" + ) + + def test_invalid_field(self, icat_query): + test_filter = PythonICATDistinctFieldFilter("my_new_field") + with pytest.raises(FilterError): + test_filter.apply_filter(icat_query) + + def test_distinct_aggregate_added(self, icat_query): + test_filter = PythonICATDistinctFieldFilter("id") + test_filter.apply_filter(icat_query) + + assert icat_query.aggregate == "DISTINCT" + + @pytest.mark.parametrize("existing_aggregate", ["COUNT", "AVG", "SUM"]) + def test_existing_aggregate_appended(self, icat_query, existing_aggregate): + icat_query.setAggregate(existing_aggregate) + + test_filter = PythonICATDistinctFieldFilter("name") + test_filter.apply_filter(icat_query) + + assert icat_query.aggregate == f"{existing_aggregate}:DISTINCT" diff --git a/test/icat/filters/test_include_filter.py b/test/icat/filters/test_include_filter.py new file mode 100644 index 00000000..05e7500e --- /dev/null +++ b/test/icat/filters/test_include_filter.py @@ -0,0 +1,74 @@ +import pytest + +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.icat.filters import PythonICATIncludeFilter + + +class TestICATIncludeFilter: + @pytest.mark.parametrize( + "filter_input, expected_output", + [ + pytest.param("investigationUsers", {"investigationUsers"}, id="string"), + pytest.param( + {"investigationUsers": "user"}, + {"investigationUsers.user"}, + id="dictionary", + ), + pytest.param( + {"datasets": ["datafiles", "sample"]}, + {"datasets.datafiles", "datasets.sample"}, + id="dictionary with list", + ), + pytest.param( + {"datasets": {"datafiles": "datafileFormat"}}, + {"datasets.datafiles.datafileFormat"}, + id="nested dictionary", + ), + pytest.param( + ["studyInvestigations", "datasets", "facility"], + {"studyInvestigations", "datasets", "facility"}, + id="list of strings", + ), + pytest.param( + [{"investigationUsers": "user"}, {"datasets": "datafiles"}], + {"investigationUsers.user", "datasets.datafiles"}, + id="list of dictionaries", + ), + pytest.param( + ["investigationUsers", ["datasets", "facility"]], + {"investigationUsers", "datasets", "facility"}, + id="nested list", + ), + ], + ) + def test_valid_input(self, icat_query, filter_input, expected_output): + test_filter = PythonICATIncludeFilter(filter_input) + test_filter.apply_filter(icat_query) + + assert icat_query.includes == expected_output + + def test_invalid_type(self, icat_query): + with pytest.raises(FilterError): + PythonICATIncludeFilter({"datasets", "facility"}) + + def test_invalid_field(self, icat_query): + test_filter = PythonICATIncludeFilter("invalidField") + with pytest.raises(FilterError): + test_filter.apply_filter(icat_query) + + @pytest.mark.parametrize( + "filter_input", + [ + pytest.param({2: "datasets"}, id="invalid dictionary key"), + pytest.param( + {"datasets": {2: "datafiles"}}, id="invalid inner dictionary key", + ), + pytest.param( + {"datasets": {"datafiles", "sample"}}, + id="invalid inner dictionary value", + ), + ], + ) + def test_invalid_extract_field(self, filter_input): + with pytest.raises(FilterError): + PythonICATIncludeFilter(filter_input) diff --git a/test/icat/filters/test_limit_filter.py b/test/icat/filters/test_limit_filter.py new file mode 100644 index 00000000..5599e22c --- /dev/null +++ b/test/icat/filters/test_limit_filter.py @@ -0,0 +1,56 @@ +import pytest + +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.filter_order_handler import FilterOrderHandler +from datagateway_api.common.icat.filters import ( + PythonICATLimitFilter, + PythonICATSkipFilter, +) + + +class TestICATLimitFilter: + @pytest.mark.parametrize( + "limit_value", + [ + pytest.param(10, id="typical"), + pytest.param(0, id="low boundary"), + pytest.param(9999, id="high boundary"), + ], + ) + def test_valid_limit_value(self, icat_query, limit_value): + test_filter = PythonICATLimitFilter(limit_value) + test_filter.apply_filter(icat_query) + + assert icat_query.limit == (0, limit_value) + + @pytest.mark.parametrize( + "limit_value", + [pytest.param(-50, id="extreme invalid"), pytest.param(-1, id="boundary")], + ) + def test_invalid_limit_value(self, icat_query, limit_value): + with pytest.raises(FilterError): + PythonICATLimitFilter(limit_value) + + @pytest.mark.parametrize( + "skip_value, limit_value", + [ + pytest.param(10, 10, id="identical typical values"), + pytest.param(0, 0, id="identical low boundary values"), + pytest.param(15, 25, id="different typical values"), + pytest.param(0, 9999, id="different boundary values"), + ], + ) + def test_limit_and_skip_merge_correctly(self, icat_query, skip_value, limit_value): + """ + Skip and limit values are set together in Python ICAT, limit value should match + max entities allowed in one transaction in ICAT as defined in ICAT properties + """ + skip_filter = PythonICATSkipFilter(skip_value) + limit_filter = PythonICATLimitFilter(limit_value) + + filter_handler = FilterOrderHandler() + filter_handler.add_filters([skip_filter, limit_filter]) + filter_handler.merge_python_icat_limit_skip_filters() + filter_handler.apply_filters(icat_query) + + assert icat_query.limit == (skip_value, limit_value) diff --git a/test/icat/filters/test_order_filter.py b/test/icat/filters/test_order_filter.py new file mode 100644 index 00000000..75c7c311 --- /dev/null +++ b/test/icat/filters/test_order_filter.py @@ -0,0 +1,60 @@ +import pytest + + +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.filter_order_handler import FilterOrderHandler +from datagateway_api.common.icat.filters import PythonICATOrderFilter + + +class TestICATOrderFilter: + def test_direction_is_uppercase(self, icat_query): + """Direction must be uppercase for Python ICAT to see the input as valid""" + test_filter = PythonICATOrderFilter("id", "asc") + + filter_handler = FilterOrderHandler() + filter_handler.add_filter(test_filter) + + assert test_filter.direction == "ASC" + + filter_handler.clear_python_icat_order_filters() + + def test_result_order_appended(self, icat_query): + id_filter = PythonICATOrderFilter("id", "ASC") + title_filter = PythonICATOrderFilter("title", "DESC") + + filter_handler = FilterOrderHandler() + filter_handler.add_filters([id_filter, title_filter]) + filter_handler.apply_filters(icat_query) + + assert PythonICATOrderFilter.result_order == [("id", "ASC"), ("title", "DESC")] + + filter_handler.clear_python_icat_order_filters() + + def test_filter_applied_to_query(self, icat_query): + test_filter = PythonICATOrderFilter("id", "DESC") + + filter_handler = FilterOrderHandler() + filter_handler.add_filter(test_filter) + filter_handler.apply_filters(icat_query) + + assert icat_query.order == [("id", "DESC")] + + filter_handler.clear_python_icat_order_filters() + + def test_invalid_field(self, icat_query): + test_filter = PythonICATOrderFilter("unknown_field", "DESC") + + filter_handler = FilterOrderHandler() + filter_handler.add_filter(test_filter) + with pytest.raises(FilterError): + filter_handler.apply_filters(icat_query) + + filter_handler.clear_python_icat_order_filters() + + def test_invalid_direction(self, icat_query): + test_filter = PythonICATOrderFilter("id", "up") + + filter_handler = FilterOrderHandler() + filter_handler.add_filter(test_filter) + with pytest.raises(FilterError): + filter_handler.apply_filters(icat_query) diff --git a/test/icat/filters/test_skip_filter.py b/test/icat/filters/test_skip_filter.py new file mode 100644 index 00000000..995b19bd --- /dev/null +++ b/test/icat/filters/test_skip_filter.py @@ -0,0 +1,27 @@ +import pytest + +from datagateway_api.common.config import config +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.icat.filters import PythonICATSkipFilter + + +class TestICATSkipFilter: + @pytest.mark.parametrize( + "skip_value", [pytest.param(10, id="typical"), pytest.param(0, id="boundary")], + ) + def test_valid_skip_value(self, icat_query, skip_value): + test_filter = PythonICATSkipFilter(skip_value) + test_filter.apply_filter(icat_query) + + assert icat_query.limit == ( + skip_value, + config.get_icat_properties()["maxEntities"], + ) + + @pytest.mark.parametrize( + "skip_value", + [pytest.param(-375, id="extreme invalid"), pytest.param(-1, id="boundary")], + ) + def test_invalid_skip_value(self, icat_query, skip_value): + with pytest.raises(FilterError): + PythonICATSkipFilter(skip_value) diff --git a/test/icat/filters/test_where_filter.py b/test/icat/filters/test_where_filter.py new file mode 100644 index 00000000..750eadff --- /dev/null +++ b/test/icat/filters/test_where_filter.py @@ -0,0 +1,67 @@ +import pytest + +from datagateway_api.common.exceptions import BadRequestError, FilterError +from datagateway_api.common.filter_order_handler import FilterOrderHandler +from datagateway_api.common.icat.filters import PythonICATWhereFilter + + +class TestICATWhereFilter: + @pytest.mark.parametrize( + "operation, value, expected_condition_value", + [ + pytest.param("eq", 5, "= '5'", id="equal"), + pytest.param("ne", 5, "!= 5", id="not equal"), + pytest.param("like", 5, "like '%5%'", id="like"), + pytest.param("lt", 5, "< '5'", id="less than"), + pytest.param("lte", 5, "<= '5'", id="less than or equal"), + pytest.param("gt", 5, "> '5'", id="greater than"), + pytest.param("gte", 5, ">= '5'", id="greater than or equal"), + pytest.param("in", [1, 2, 3, 4], "in (1, 2, 3, 4)", id="in a list"), + ], + ) + def test_valid_operations( + self, icat_query, operation, value, expected_condition_value, + ): + test_filter = PythonICATWhereFilter("id", value, operation) + test_filter.apply_filter(icat_query) + + assert icat_query.conditions == {"id": expected_condition_value} + + def test_invalid_in_operation(self, icat_query): + with pytest.raises(BadRequestError): + PythonICATWhereFilter("id", "1, 2, 3, 4, 5", "in") + + def test_invalid_operation(self, icat_query): + test_filter = PythonICATWhereFilter("id", 10, "non") + + with pytest.raises(FilterError): + test_filter.apply_filter(icat_query) + + def test_valid_internal_icat_value(self, icat_query): + """Check that values that point to other values in the schema are applied""" + test_filter = PythonICATWhereFilter("startDate", "o.endDate", "lt") + test_filter.apply_filter(icat_query) + + assert icat_query.conditions == {"startDate": "< o.endDate"} + + def test_valid_field(self, icat_query): + test_filter = PythonICATWhereFilter("title", "Investigation Title", "eq") + test_filter.apply_filter(icat_query) + + assert icat_query.conditions == {"title": "= 'Investigation Title'"} + + def test_invalid_field(self, icat_query): + test_filter = PythonICATWhereFilter("random_field", "my_value", "eq") + + with pytest.raises(FilterError): + test_filter.apply_filter(icat_query) + + def test_multiple_conditions_per_field(self, icat_query): + lt_filter = PythonICATWhereFilter("id", 10, "lt") + gt_filter = PythonICATWhereFilter("id", 5, "gt") + + filter_handler = FilterOrderHandler() + filter_handler.add_filters([lt_filter, gt_filter]) + filter_handler.apply_filters(icat_query) + + assert icat_query.conditions == {"id": ["< '10'", "> '5'"]} diff --git a/test/icat/test_filter_order_handler.py b/test/icat/test_filter_order_handler.py new file mode 100644 index 00000000..b31fe13d --- /dev/null +++ b/test/icat/test_filter_order_handler.py @@ -0,0 +1,69 @@ +import pytest + +from datagateway_api.common.filter_order_handler import FilterOrderHandler +from datagateway_api.common.icat.filters import ( + PythonICATLimitFilter, + PythonICATWhereFilter, +) + + +class TestFilterOrderHandler: + """ + `merge_python_icat_limit_skip_filters` and`clear_python_icat_order_filters()` are + tested while testing the ICAT backend filters, so tests of these functions won't be + found here + """ + + def test_add_filter(self, icat_query): + test_handler = FilterOrderHandler() + test_filter = PythonICATWhereFilter("id", 2, "eq") + + test_handler.add_filter(test_filter) + + assert test_handler.filters == [test_filter] + + def test_add_filters(self): + test_handler = FilterOrderHandler() + id_filter = PythonICATWhereFilter("id", 2, "eq") + name_filter = PythonICATWhereFilter("name", "New Name", "like") + filter_list = [id_filter, name_filter] + + test_handler.add_filters(filter_list) + + assert test_handler.filters == filter_list + + def test_remove_filter(self): + test_filter = PythonICATWhereFilter("id", 2, "eq") + + test_handler = FilterOrderHandler() + test_handler.add_filter(test_filter) + test_handler.remove_filter(test_filter) + + assert test_handler.filters == [] + + def test_remove_not_added_filter(self): + test_handler = FilterOrderHandler() + test_filter = PythonICATWhereFilter("id", 2, "eq") + + with pytest.raises(ValueError): + test_handler.remove_filter(test_filter) + + def test_sort_filters(self): + limit_filter = PythonICATLimitFilter(10) + where_filter = PythonICATWhereFilter("id", 2, "eq") + + test_handler = FilterOrderHandler() + test_handler.add_filters([limit_filter, where_filter]) + test_handler.sort_filters() + + assert test_handler.filters == [where_filter, limit_filter] + + def test_apply_filters(self, icat_query): + where_filter = PythonICATWhereFilter("id", 2, "eq") + limit_filter = PythonICATLimitFilter(10) + + test_handler = FilterOrderHandler() + test_handler.add_filters([where_filter, limit_filter]) + test_handler.apply_filters(icat_query) + + assert icat_query.conditions == {"id": "= '2'"} and icat_query.limit == (0, 10) diff --git a/test/icat/test_query.py b/test/icat/test_query.py new file mode 100644 index 00000000..6def14b8 --- /dev/null +++ b/test/icat/test_query.py @@ -0,0 +1,115 @@ +from datetime import datetime + +from icat.entity import Entity +import pytest + +from datagateway_api.common.date_handler import DateHandler +from datagateway_api.common.exceptions import PythonICATError +from datagateway_api.common.icat.filters import ( + PythonICATSkipFilter, + PythonICATWhereFilter, +) +from datagateway_api.common.icat.query import ICATQuery + + +def prepare_icat_data_for_assertion(data, remove_id=False): + """ + Remove meta attributes from ICAT data. Meta attributes contain data about data + creation/modification, and should be removed to ensure correct assertion values + + :param data: ICAT data containing meta attributes such as modTime + :type data: :class:`list` or :class:`icat.entity.EntityList` + """ + assertable_data = [] + meta_attributes = Entity.MetaAttr + + for entity in data: + # Convert to dictionary if an ICAT entity object + if isinstance(entity, Entity): + entity = entity.as_dict() + + for attr in meta_attributes: + entity.pop(attr) + + for attr in entity.keys(): + if isinstance(entity[attr], datetime): + entity[attr] = DateHandler.datetime_object_to_str(entity[attr]) + + # meta_attributes is immutable + if remove_id: + entity.pop("id") + + assertable_data.append(entity) + + return assertable_data + + +class TestICATQuery: + def test_valid_query_creation(self, icat_client): + # Paramatise and add inputs for conditions, aggregate and includes + test_query = ICATQuery(icat_client, "User") + + assert test_query.query.entity == icat_client.getEntityClass("User") + + def test_invalid_query_creation(self, icat_client): + with pytest.raises(PythonICATError): + ICATQuery(icat_client, "User", conditions={"invalid": "invalid"}) + + def test_valid_query_exeuction( + self, icat_client, single_investigation_test_data, + ): + test_query = ICATQuery(icat_client, "Investigation") + test_data_filter = PythonICATWhereFilter( + "title", "Test data for the Python ICAT Backend on DataGateway API", "like", + ) + test_data_filter.apply_filter(test_query.query) + query_data = test_query.execute_query(icat_client) + + query_output_dicts = prepare_icat_data_for_assertion(query_data) + + assert query_output_dicts == single_investigation_test_data + + def test_invalid_query_execution(self, icat_client): + test_query = ICATQuery(icat_client, "Investigation") + + # Create filter with valid value, then change to invalid value that'll cause 500 + test_skip_filter = PythonICATSkipFilter(1) + test_skip_filter.skip_value = -1 + test_skip_filter.apply_filter(test_query.query) + + with pytest.raises(PythonICATError): + test_query.execute_query(icat_client) + + def test_json_format_execution_output( + self, icat_client, single_investigation_test_data, + ): + test_query = ICATQuery(icat_client, "Investigation") + test_data_filter = PythonICATWhereFilter( + "title", "Test data for the Python ICAT Backend on DataGateway API", "like", + ) + test_data_filter.apply_filter(test_query.query) + query_data = test_query.execute_query(icat_client, True) + + query_output_json = prepare_icat_data_for_assertion(query_data) + + assert query_output_json == single_investigation_test_data + + def test_include_fields_list_flatten(self, icat_client): + included_field_set = { + "investigationUsers.investigation.datasets", + "userGroups", + "instrumentScientists", + "studies", + } + + test_query = ICATQuery(icat_client, "User") + flat_list = test_query.flatten_query_included_fields(included_field_set) + + assert flat_list == [ + "instrumentScientists", + "investigationUsers", + "investigation", + "datasets", + "studies", + "userGroups", + ] diff --git a/test/icat/test_session_handling.py b/test/icat/test_session_handling.py new file mode 100644 index 00000000..2018ca01 --- /dev/null +++ b/test/icat/test_session_handling.py @@ -0,0 +1,115 @@ +from datetime import datetime + +from icat.client import Client +import pytest + +from datagateway_api.common.config import config +from datagateway_api.common.icat.filters import PythonICATWhereFilter + + +class TestSessionHandling: + def test_get_valid_session_details( + self, flask_test_app_icat, valid_icat_credentials_header, + ): + session_details = flask_test_app_icat.get( + "/sessions", headers=valid_icat_credentials_header, + ) + + session_expiry_datetime = datetime.strptime( + session_details.json["EXPIREDATETIME"], "%Y-%m-%d %H:%M:%S.%f", + ) + current_datetime = datetime.now() + time_diff = abs(session_expiry_datetime - current_datetime) + time_diff_minutes = time_diff.seconds / 60 + + # Allows a bit of leeway for slow test execution + assert time_diff_minutes < 120 and time_diff_minutes >= 118 + + # Check username is correct + assert ( + session_details.json["USERNAME"] == f"{config.get_test_mechanism()}/" + f"{config.get_test_user_credentials()['username']}" + ) + + # Check session ID matches the header from the request + assert ( + session_details.json["ID"] + == valid_icat_credentials_header["Authorization"].split()[1] + ) + + def test_get_invalid_session_details( + self, bad_credentials_header, flask_test_app_icat, + ): + session_details = flask_test_app_icat.get( + "/sessions", headers=bad_credentials_header, + ) + + assert session_details.status_code == 403 + + def test_refresh_session(self, valid_icat_credentials_header, flask_test_app_icat): + pre_refresh_session_details = flask_test_app_icat.get( + "/sessions", headers=valid_icat_credentials_header, + ) + + refresh_session = flask_test_app_icat.put( + "/sessions", headers=valid_icat_credentials_header, + ) + + post_refresh_session_details = flask_test_app_icat.get( + "/sessions", headers=valid_icat_credentials_header, + ) + + assert refresh_session.status_code == 200 + + assert ( + pre_refresh_session_details.json["EXPIREDATETIME"] + != post_refresh_session_details.json["EXPIREDATETIME"] + ) + + @pytest.mark.usefixtures("single_investigation_test_data") + def test_valid_login(self, flask_test_app_icat, icat_client, icat_query): + user_credentials = config.get_test_user_credentials() + + login_json = { + "username": user_credentials["username"], + "password": user_credentials["password"], + "mechanism": config.get_test_mechanism(), + } + login_response = flask_test_app_icat.post("/sessions", json=login_json) + + icat_client.sessionId = login_response.json["sessionID"] + icat_query.setAggregate("COUNT") + title_filter = PythonICATWhereFilter( + "title", "Test data for the Python ICAT Backend on DataGateway API", "like", + ) + title_filter.apply_filter(icat_query) + + test_query = icat_client.search(icat_query) + + assert test_query == [1] and login_response.status_code == 201 + + def test_invalid_login(self, flask_test_app_icat): + login_json = { + "username": "Invalid Username", + "password": "InvalidPassword", + "mechanism": config.get_test_mechanism(), + } + login_response = flask_test_app_icat.post("/sessions", json=login_json) + + assert login_response.status_code == 403 + + def test_valid_logout(self, flask_test_app_icat): + client = Client(config.get_icat_url(), checkCert=config.get_icat_check_cert()) + client.login(config.get_test_mechanism(), config.get_test_user_credentials()) + creds_header = {"Authorization": f"Bearer {client.sessionId}"} + + logout_response = flask_test_app_icat.delete("/sessions", headers=creds_header) + + assert logout_response.status_code == 200 + + def test_invalid_logout(self, bad_credentials_header, flask_test_app_icat): + logout_response = flask_test_app_icat.delete( + "/sessions", headers=bad_credentials_header, + ) + + assert logout_response.status_code == 403 diff --git a/test/test_backends.py b/test/test_backends.py new file mode 100644 index 00000000..dc79e7c9 --- /dev/null +++ b/test/test_backends.py @@ -0,0 +1,75 @@ +import pytest + +from datagateway_api.common.backend import Backend +from datagateway_api.common.backends import create_backend +from datagateway_api.common.database.backend import DatabaseBackend +from datagateway_api.common.icat.backend import PythonICATBackend + + +class TestBackends: + @pytest.mark.parametrize( + "backend_name, backend_type", + [ + pytest.param("db", DatabaseBackend, id="Database Backend"), + pytest.param("python_icat", PythonICATBackend, id="Python ICAT Backend"), + ], + ) + def test_backend_creation(self, backend_name, backend_type): + test_backend = create_backend(backend_name) + + assert type(test_backend) == backend_type + + def test_abstract_class(self): + """Test the `Backend` abstract class has all the required classes for the API""" + Backend.__abstractmethods__ = set() + + class DummyBackend(Backend): + pass + + d = DummyBackend() + + credentials = "credentials" + session_id = "session_id" + entity_type = "entity_type" + filters = "filters" + data = "data" + instrument_id = "instrument_id" + facilitycycle_id = "facilitycycle_id" + id_ = "id_" + + assert d.login(credentials) is None + assert d.get_session_details(session_id) is None + assert d.refresh(session_id) is None + assert d.logout(session_id) is None + assert d.get_with_filters(session_id, entity_type, filters) is None + assert d.create(session_id, entity_type, data) is None + assert d.update(session_id, entity_type, data) is None + assert d.get_one_with_filters(session_id, entity_type, filters) is None + assert d.count_with_filters(session_id, entity_type, filters) is None + assert d.get_with_id(session_id, entity_type, id_) is None + assert d.delete_with_id(session_id, entity_type, id_) is None + assert d.update_with_id(session_id, entity_type, id_, data) is None + assert ( + d.get_facility_cycles_for_instrument_with_filters( + session_id, instrument_id, filters, + ) + is None + ) + assert ( + d.get_facility_cycles_for_instrument_count_with_filters( + session_id, instrument_id, filters, + ) + is None + ) + assert ( + d.get_investigations_for_instrument_facility_cycle_with_filters( + session_id, instrument_id, facilitycycle_id, filters, + ) + is None + ) + assert ( + d.get_investigation_count_instrument_facility_cycle_with_filters( + session_id, instrument_id, facilitycycle_id, filters, + ) + is None + ) diff --git a/test/test_base.py b/test/test_base.py deleted file mode 100644 index 31677e1d..00000000 --- a/test/test_base.py +++ /dev/null @@ -1,13 +0,0 @@ -from unittest import TestCase - -from datagateway_api.src.main import app - - -class FlaskAppTest(TestCase): - """ - The FlaskAppTest Base class sets up a test client to be used to mock requests - """ - - def setUp(self): - app.config["TESTING"] = True - self.app = app.test_client() diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 00000000..647ad98f --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,160 @@ +from pathlib import Path +import tempfile + +import pytest + +from datagateway_api.common.config import Config + + +@pytest.fixture() +def valid_config(): + return Config(path=Path(__file__).parent.parent / "config.json.example") + + +@pytest.fixture() +def invalid_config(): + blank_config_file = tempfile.NamedTemporaryFile(mode="w+", suffix=".json") + blank_config_file.write("{}") + blank_config_file.seek(0) + + return Config(path=blank_config_file.name) + + +class TestGetBackendType: + def test_valid_backend_type(self, valid_config): + backend_type = valid_config.get_backend_type() + assert backend_type == "db" + + def test_invalid_backend_type(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_backend_type() + + +class TestGetDBURL: + def test_valid_db_url(self, valid_config): + db_url = valid_config.get_db_url() + assert db_url == "mysql+pymysql://root:rootpw@localhost:13306/icatdb" + + def test_invalid_db_url(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_db_url() + + +class TestICATURL: + def test_valid_icat_url(self, valid_config): + icat_url = valid_config.get_icat_url() + assert icat_url == "https://localhost.localdomain:8181" + + def test_invalid_icat_url(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_icat_url() + + +class TestICATCheckCert: + def test_valid_icat_check_cert(self, valid_config): + icat_check_cert = valid_config.get_icat_check_cert() + assert icat_check_cert is False + + def test_invalid_icat_check_cert(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_icat_check_cert() + + +class TestGetLogLevel: + def test_valid_log_level(self, valid_config): + log_level = valid_config.get_log_level() + assert log_level == "WARN" + + def test_invalid_log_level(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_log_level() + + +class TestGetLogLocation: + def test_valid_log_location(self, valid_config): + log_location = valid_config.get_log_location() + assert log_location == "/home/user1/datagateway-api/logs.log" + + def test_invalid_log_location(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_log_location() + + +class TestIsDebugMode: + def test_valid_debug_mode(self, valid_config): + debug_mode = valid_config.is_debug_mode() + assert debug_mode is False + + def test_invalid_debug_mode(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.is_debug_mode() + + +class TestIsGenerateSwagger: + def test_valid_generate_swagger(self, valid_config): + generate_swagger = valid_config.is_generate_swagger() + assert generate_swagger is False + + def test_invalid_generate_swagger(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.is_generate_swagger() + + +class TestGetHost: + def test_valid_host(self, valid_config): + host = valid_config.get_host() + assert host == "127.0.0.1" + + def test_invalid_host(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_host() + + +class TestGetPort: + def test_valid_port(self, valid_config): + port = valid_config.get_port() + assert port == "5000" + + def test_invalid_port(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_icat_url() + + +class TestGetTestUserCredentials: + def test_valid_test_user_credentials(self, valid_config): + test_user_credentials = valid_config.get_test_user_credentials() + assert test_user_credentials == {"username": "root", "password": "pw"} + + def test_invalid_test_user_credentials(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_test_user_credentials() + + +class TestGetTestMechanism: + def test_valid_test_mechanism(self, valid_config): + test_mechanism = valid_config.get_test_mechanism() + assert test_mechanism == "simple" + + def test_invalid_test_mechanism(self, invalid_config): + with pytest.raises(SystemExit): + invalid_config.get_test_mechanism() + + +class TestGetICATProperties: + def test_valid_icat_properties(self, valid_config): + example_icat_properties = { + "maxEntities": 10000, + "lifetimeMinutes": 120, + "authenticators": [ + { + "mnemonic": "simple", + "keys": [{"name": "username"}, {"name": "password", "hide": True}], + "friendly": "Simple", + }, + ], + "containerType": "Glassfish", + } + + icat_properties = valid_config.get_icat_properties() + # Values could vary across versions, less likely that keys will + assert icat_properties.keys() == example_icat_properties.keys() diff --git a/test/test_database_helpers.py b/test/test_database_helpers.py deleted file mode 100644 index c97404a5..00000000 --- a/test/test_database_helpers.py +++ /dev/null @@ -1,99 +0,0 @@ -from unittest import TestCase - -from datagateway_api.common.config import config -from datagateway_api.common.database.helpers import QueryFilterFactory -from datagateway_api.common.exceptions import ApiError - -backend_type = config.get_backend_type() -if backend_type == "db": - from datagateway_api.common.database.filters import ( - DatabaseDistinctFieldFilter as DistinctFieldFilter, - DatabaseIncludeFilter as IncludeFilter, - DatabaseLimitFilter as LimitFilter, - DatabaseOrderFilter as OrderFilter, - DatabaseSkipFilter as SkipFilter, - DatabaseWhereFilter as WhereFilter, - ) -elif backend_type == "python_icat": - # TODO - Adapt these tests for the ICAT implementation of filters - from datagateway_api.common.icat.filters import ( - PythonICATDistinctFieldFilter as DistinctFieldFilter, - PythonICATIncludeFilter as IncludeFilter, - PythonICATLimitFilter as LimitFilter, - PythonICATOrderFilter as OrderFilter, - PythonICATSkipFilter as SkipFilter, - PythonICATWhereFilter as WhereFilter, - ) -else: - raise ApiError( - "Cannot select which implementation of filters to import, check the config file" - " has a valid backend type", - ) - - -class TestQueryFilterFactory(TestCase): - def test_order_filter(self): - self.assertIs( - OrderFilter, - type(QueryFilterFactory.get_query_filter({"order": "ID DESC"})), - ) - - def test_limit_filter(self): - self.assertIs( - LimitFilter, type(QueryFilterFactory.get_query_filter({"limit": 10})), - ) - - def test_skip_filter(self): - self.assertIs( - SkipFilter, type(QueryFilterFactory.get_query_filter({"skip": 10})), - ) - - def test_where_filter(self): - self.assertIs( - WhereFilter, - type(QueryFilterFactory.get_query_filter({"where": {"ID": {"eq": "1"}}})), - ) - self.assertIs( - WhereFilter, - type(QueryFilterFactory.get_query_filter({"where": {"ID": {"lte": "1"}}})), - ) - self.assertIs( - WhereFilter, - type(QueryFilterFactory.get_query_filter({"where": {"ID": {"gte": "1"}}})), - ) - self.assertIs( - WhereFilter, - type(QueryFilterFactory.get_query_filter({"where": {"ID": {"like": "3"}}})), - ) - self.assertIs( - WhereFilter, - type( - QueryFilterFactory.get_query_filter( - {"where": {"ID": {"in": ["1", "2", "3"]}}}, - ), - ), - ) - - def test_include_filter(self): - self.assertIs( - IncludeFilter, - type(QueryFilterFactory.get_query_filter({"include": "DATAFILE"})), - ) - self.assertIs( - IncludeFilter, - type(QueryFilterFactory.get_query_filter({"include": ["TEST"]})), - ) - self.assertIs( - IncludeFilter, - type( - QueryFilterFactory.get_query_filter( - {"include": {"Test": ["TEST1", "Test2"]}}, - ), - ), - ) - - def test_distinct_filter(self): - self.assertIs( - DistinctFieldFilter, - type(QueryFilterFactory.get_query_filter({"distinct": "TEST"})), - ) diff --git a/test/test_date_handler.py b/test/test_date_handler.py new file mode 100644 index 00000000..8de17fed --- /dev/null +++ b/test/test_date_handler.py @@ -0,0 +1,73 @@ +from datetime import datetime + +import pytest + +from datagateway_api.common.date_handler import DateHandler +from datagateway_api.common.exceptions import BadRequestError + + +class TestIsStrADate: + def test_valid_date(self): + date_output = DateHandler.is_str_a_date("2008-10-15") + assert date_output is True + + def test_valid_boundary_date(self): + date_output = DateHandler.is_str_a_date("29/2/2020") + assert date_output is True + + def test_invalid_boundary_date(self): + date_output = DateHandler.is_str_a_date("29/2/2019") + # There was no leap year in 2019 + assert date_output is False + + def test_invalid_date(self): + date_output = DateHandler.is_str_a_date("25/25/2020") + assert date_output is False + + +class TestStrToDatetime: + def test_valid_str(self): + datetime_output = DateHandler.str_to_datetime_object("2008-10-15 12:05:09") + assert datetime_output == datetime( + year=2008, month=10, day=15, hour=12, minute=5, second=9, + ) + + def test_valid_boundary_str(self): + datetime_output = DateHandler.str_to_datetime_object("2020-02-29 20:20:20") + assert datetime_output == datetime( + year=2020, month=2, day=29, hour=20, minute=20, second=20, + ) + + def test_invalid_boundary_str(self): + with pytest.raises(BadRequestError): + DateHandler.str_to_datetime_object("2019-02-29 12:05:09") + + def test_invalid_str_format_symbols(self): + with pytest.raises(BadRequestError): + DateHandler.str_to_datetime_object("2019/10/05 12:05:09") + + def test_invalid_str_format_order(self): + with pytest.raises(BadRequestError): + DateHandler.str_to_datetime_object("12:05:09 2019-10-05") + + +class TestDatetimeToStr: + def test_valid_datetime(self): + example_date = datetime( + year=2008, month=10, day=15, hour=12, minute=5, second=9, + ) + str_date_output = DateHandler.datetime_object_to_str(example_date) + assert str_date_output == "2008-10-15 12:05:09" + + def test_valid_datetime_no_time(self): + example_date = datetime(year=2008, month=10, day=15) + str_date_output = DateHandler.datetime_object_to_str(example_date) + assert str_date_output == "2008-10-15 00:00:00" + + def test_valid_boundary_datetime(self): + # Can't test invalid leap years as invalid datetime objects can't be created + example_date = datetime( + year=2020, month=2, day=29, hour=23, minute=59, second=59, + ) + str_date_output = DateHandler.datetime_object_to_str(example_date) + assert str_date_output == "2020-02-29 23:59:59" diff --git a/test/test_endpoint_rules.py b/test/test_endpoint_rules.py new file mode 100644 index 00000000..f703e4f6 --- /dev/null +++ b/test/test_endpoint_rules.py @@ -0,0 +1,75 @@ +import pytest + +from datagateway_api.src.resources.entities.entity_map import endpoints + + +class TestEndpointRules: + """ + Test class to ensure all endpoints on the API exist & have the correct HTTP methods + """ + + @pytest.mark.parametrize( + "endpoint_ending, expected_methods", + [ + pytest.param("/findone", ["GET"], id="findone"), + pytest.param("/count", ["GET"], id="count"), + pytest.param("/", ["DELETE", "GET", "PATCH"], id="id"), + pytest.param("", ["GET", "PATCH", "POST"], id="typical endpoints"), + ], + ) + def test_entity_endpoints(self, flask_test_app, endpoint_ending, expected_methods): + for endpoint_entity in endpoints.keys(): + endpoint_found = False + + for rule in flask_test_app.url_map.iter_rules(): + if f"/{endpoint_entity.lower()}{endpoint_ending}" == rule.rule: + endpoint_found = True + + for method_name in expected_methods: + # Can't do a simple equality check as .methods contains other + # methods not added by the API which aren't utilised + assert method_name in rule.methods + + assert endpoint_found + + @pytest.mark.parametrize( + "endpoint_name, expected_methods", + [ + pytest.param("/sessions", ["DELETE", "GET", "POST", "PUT"], id="sessions"), + pytest.param( + "/instruments//facilitycycles", + ["GET"], + id="ISIS instrument's facility cycles", + ), + pytest.param( + "/instruments//facilitycycles/count", + ["GET"], + id="count ISIS instrument's facility cycles", + ), + pytest.param( + "/instruments//facilitycycles/" + "/investigations", + ["GET"], + id="ISIS investigations", + ), + pytest.param( + "/instruments//facilitycycles/" + "/investigations/count", + ["GET"], + id="count ISIS investigations", + ), + ], + ) + def test_non_entity_endpoints( + self, flask_test_app, endpoint_name, expected_methods, + ): + endpoint_found = False + + for rule in flask_test_app.url_map.iter_rules(): + if endpoint_name == rule.rule: + endpoint_found = True + + for method_name in expected_methods: + assert method_name in rule.methods + + assert endpoint_found diff --git a/test/test_entityHelper.py b/test/test_entityHelper.py deleted file mode 100644 index 9fac7c99..00000000 --- a/test/test_entityHelper.py +++ /dev/null @@ -1,171 +0,0 @@ -import datetime -from unittest import TestCase - -from datagateway_api.common.database.models import ( - DATAFILE, - DATAFILEFORMAT, - DATASET, - INVESTIGATION, -) - - -class TestEntityHelper(TestCase): - def setUp(self): - self.dataset = DATASET() - self.investigation = INVESTIGATION() - self.dataset.INVESTIGATION = self.investigation - self.datafileformat = DATAFILEFORMAT() - self.datafile = DATAFILE() - self.datafile.ID = 1 - self.datafile.LOCATION = "test location" - self.datafile.DATASET = self.dataset - self.datafile.DATAFILEFORMAT = self.datafileformat - self.datafile.NAME = "test name" - self.datafile.MOD_TIME = datetime.datetime(2000, 1, 1) - self.datafile.CREATE_TIME = datetime.datetime(2000, 1, 1) - self.datafile.CHECKSUM = "test checksum" - self.datafile.FILESIZE = 64 - self.datafile.DATAFILEMODTIME = datetime.datetime(2000, 1, 1) - self.datafile.DATAFILECREATETIME = datetime.datetime(2000, 1, 1) - self.datafile.DATASET_ID = 1 - self.datafile.DOI = "test doi" - self.datafile.DESCRIPTION = "test description" - self.datafile.CREATE_ID = "test create id" - self.datafile.MOD_ID = "test mod id" - self.datafile.DATAFILEFORMAT_ID = 1 - - def test_to_dict(self): - expected_dict = { - "ID": 1, - "LOCATION": "test location", - "NAME": "test name", - "MOD_TIME": str(datetime.datetime(2000, 1, 1)), - "CHECKSUM": "test checksum", - "FILESIZE": 64, - "DATAFILEMODTIME": str(datetime.datetime(2000, 1, 1)), - "DATAFILECREATETIME": str(datetime.datetime(2000, 1, 1)), - "DATASET_ID": 1, - "DOI": "test doi", - "DESCRIPTION": "test description", - "CREATE_ID": "test create id", - "MOD_ID": "test mod id", - "DATAFILEFORMAT_ID": 1, - "CREATE_TIME": str(datetime.datetime(2000, 1, 1)), - } - self.assertEqual(expected_dict, self.datafile.to_dict()) - - def test_to_nested_dict(self): - expected_dict = { - "ID": 1, - "LOCATION": "test location", - "NAME": "test name", - "MOD_TIME": str(datetime.datetime(2000, 1, 1)), - "CHECKSUM": "test checksum", - "FILESIZE": 64, - "DATAFILEMODTIME": str(datetime.datetime(2000, 1, 1)), - "DATAFILECREATETIME": str(datetime.datetime(2000, 1, 1)), - "DATASET_ID": 1, - "DOI": "test doi", - "DESCRIPTION": "test description", - "CREATE_ID": "test create id", - "MOD_ID": "test mod id", - "DATAFILEFORMAT_ID": 1, - "CREATE_TIME": str(datetime.datetime(2000, 1, 1)), - "DATASET": { - "ID": None, - "CREATE_TIME": None, - "MOD_TIME": None, - "CREATE_ID": None, - "MOD_ID": None, - "INVESTIGATION_ID": None, - "COMPLETE": None, - "DESCRIPTION": None, - "DOI": None, - "END_DATE": None, - "LOCATION": None, - "NAME": None, - "STARTDATE": None, - "SAMPLE_ID": None, - "TYPE_ID": None, - }, - } - self.assertEqual(expected_dict, self.datafile.to_nested_dict("DATASET")) - expected_dict = { - "ID": 1, - "LOCATION": "test location", - "NAME": "test name", - "MOD_TIME": str(datetime.datetime(2000, 1, 1)), - "CHECKSUM": "test checksum", - "FILESIZE": 64, - "DATAFILEMODTIME": str(datetime.datetime(2000, 1, 1)), - "DATAFILECREATETIME": str(datetime.datetime(2000, 1, 1)), - "DATASET_ID": 1, - "DOI": "test doi", - "DESCRIPTION": "test description", - "CREATE_ID": "test create id", - "MOD_ID": "test mod id", - "DATAFILEFORMAT_ID": 1, - "CREATE_TIME": str(datetime.datetime(2000, 1, 1)), - "DATASET": { - "ID": None, - "CREATE_TIME": None, - "MOD_TIME": None, - "CREATE_ID": None, - "MOD_ID": None, - "INVESTIGATION_ID": None, - "COMPLETE": None, - "DESCRIPTION": None, - "DOI": None, - "END_DATE": None, - "LOCATION": None, - "NAME": None, - "STARTDATE": None, - "SAMPLE_ID": None, - "TYPE_ID": None, - "INVESTIGATION": { - "ID": None, - "CREATE_ID": None, - "CREATE_TIME": None, - "DOI": None, - "ENDDATE": None, - "MOD_ID": None, - "MOD_TIME": None, - "NAME": None, - "RELEASEDATE": None, - "STARTDATE": None, - "SUMMARY": None, - "TITLE": None, - "VISIT_ID": None, - "FACILITY_ID": None, - "TYPE_ID": None, - }, - }, - } - self.assertEqual( - expected_dict, self.datafile.to_nested_dict({"DATASET": "INVESTIGATION"}), - ) - - def test_get_related_entity(self): - self.assertEqual(self.dataset, self.datafile.get_related_entity("DATASET")) - - def test_update_from_dict(self): - datafile = DATAFILE() - dictionary = { - "ID": 1, - "LOCATION": "test location", - "NAME": "test name", - "MOD_TIME": str(datetime.datetime(2000, 1, 1)), - "CHECKSUM": "test checksum", - "FILESIZE": 64, - "DATAFILEMODTIME": str(datetime.datetime(2000, 1, 1)), - "DATAFILECREATETIME": str(datetime.datetime(2000, 1, 1)), - "DATASET_ID": 1, - "DOI": "test doi", - "DESCRIPTION": "test description", - "CREATE_ID": "test create id", - "MOD_ID": "test mod id", - "DATAFILEFORMAT_ID": 1, - "CREATE_TIME": str(datetime.datetime(2000, 1, 1)), - } - datafile.update_from_dict(dictionary) - self.assertEqual(dictionary, datafile.to_dict()) diff --git a/test/test_get_filters_from_query.py b/test/test_get_filters_from_query.py new file mode 100644 index 00000000..2a0a3a48 --- /dev/null +++ b/test/test_get_filters_from_query.py @@ -0,0 +1,54 @@ +import pytest + +from datagateway_api.common.database.filters import ( + DatabaseDistinctFieldFilter, + DatabaseIncludeFilter, + DatabaseLimitFilter, + DatabaseOrderFilter, + DatabaseSkipFilter, +) +from datagateway_api.common.exceptions import FilterError +from datagateway_api.common.helpers import get_filters_from_query_string + + +class TestGetFiltersFromQueryString: + def test_valid_no_filters(self, flask_test_app_db): + with flask_test_app_db: + flask_test_app_db.get("/") + + assert [] == get_filters_from_query_string() + + def test_invalid_filter(self, flask_test_app_db): + with flask_test_app_db: + flask_test_app_db.get('/?test="test"') + + with pytest.raises(FilterError): + get_filters_from_query_string() + + @pytest.mark.parametrize( + "filter_input, filter_type", + [ + pytest.param( + 'distinct="ID"', DatabaseDistinctFieldFilter, id="DB distinct filter", + ), + pytest.param( + 'include="TEST"', DatabaseIncludeFilter, id="DB include filter", + ), + pytest.param("limit=10", DatabaseLimitFilter, id="DB limit filter"), + pytest.param('order="ID DESC"', DatabaseOrderFilter, id="DB order filter"), + pytest.param("skip=10", DatabaseSkipFilter, id="DB skip filter"), + ], + ) + def test_valid_filter(self, flask_test_app_db, filter_input, filter_type): + with flask_test_app_db: + flask_test_app_db.get(f"/?{filter_input}") + filters = get_filters_from_query_string() + + assert isinstance(filters[0], filter_type) + + def test_valid_multiple_filters(self, flask_test_app_db): + with flask_test_app_db: + flask_test_app_db.get("/?limit=10&skip=4") + filters = get_filters_from_query_string() + + assert len(filters) == 2 diff --git a/test/test_get_session_id_from_auth_header.py b/test/test_get_session_id_from_auth_header.py new file mode 100644 index 00000000..f28a5ce7 --- /dev/null +++ b/test/test_get_session_id_from_auth_header.py @@ -0,0 +1,28 @@ +import pytest + +from datagateway_api.common.exceptions import ( + AuthenticationError, + MissingCredentialsError, +) +from datagateway_api.common.helpers import get_session_id_from_auth_header + + +class TestGetSessionIDFromAuthHeader: + def test_invalid_no_credentials_in_header(self, flask_test_app_db): + with flask_test_app_db: + flask_test_app_db.get("/") + with pytest.raises(MissingCredentialsError): + get_session_id_from_auth_header() + + def test_invalid_header(self, flask_test_app_db, invalid_credentials_header): + with flask_test_app_db: + flask_test_app_db.get("/", headers=invalid_credentials_header) + with pytest.raises(AuthenticationError): + get_session_id_from_auth_header() + + def test_valid_header(self, flask_test_app_db, valid_db_credentials_header): + with flask_test_app_db: + flask_test_app_db.get("/", headers=valid_db_credentials_header) + session_id = valid_db_credentials_header["Authorization"].split()[1] + + assert session_id == get_session_id_from_auth_header() diff --git a/test/test_helpers.py b/test/test_helpers.py deleted file mode 100644 index ca39f2de..00000000 --- a/test/test_helpers.py +++ /dev/null @@ -1,268 +0,0 @@ -from datetime import datetime, timedelta -from unittest import TestCase - -from sqlalchemy.exc import IntegrityError - -from datagateway_api.common.database.helpers import ( - delete_row_by_id, - DistinctFieldFilter, - IncludeFilter, - insert_row_into_table, - LimitFilter, - OrderFilter, - SkipFilter, - WhereFilter, -) -from datagateway_api.common.database.models import SESSION -from datagateway_api.common.exceptions import ( - AuthenticationError, - BadRequestError, - FilterError, - MissingCredentialsError, - MissingRecordError, -) -from datagateway_api.common.helpers import ( - get_filters_from_query_string, - get_session_id_from_auth_header, - is_valid_json, - queries_records, -) -from test.test_base import FlaskAppTest - - -class TestIsValidJSON(TestCase): - def test_array(self): - self.assertTrue(is_valid_json("[]")) - - def test_null(self): - self.assertTrue("null") - - def test_valid_json(self): - self.assertTrue(is_valid_json('{"test":1}')) - - def test_single_quoted_json(self): - self.assertFalse(is_valid_json("{'test':1}")) - - def test_none(self): - self.assertFalse(is_valid_json(None)) - - def test_int(self): - self.assertFalse(is_valid_json(1)) - - def test_dict(self): - self.assertFalse(is_valid_json({"test": 1})) - - def test_list(self): - self.assertFalse(is_valid_json([])) - - -class TestRequiresSessionID(FlaskAppTest): - def setUp(self): - super().setUp() - self.good_credentials_header = {"Authorization": "Bearer Test"} - self.invalid_credentials_header = {"Authorization": "Test"} - self.bad_credentials_header = {"Authorization": "Bearer BadTest"} - session = SESSION() - session.ID = "Test" - session.EXPIREDATETIME = datetime.now() + timedelta(hours=1) - session.username = "Test User" - - insert_row_into_table(SESSION, session) - - def tearDown(self): - delete_row_by_id(SESSION, "Test") - - def test_missing_credentials(self): - self.assertEqual(401, self.app.get("/datafiles").status_code) - - def test_invalid_credentials(self): - self.assertEqual( - 403, - self.app.get( - "/datafiles", headers=self.invalid_credentials_header, - ).status_code, - ) - - def test_bad_credentials(self): - self.assertEqual( - 403, - self.app.get("/datafiles", headers=self.bad_credentials_header).status_code, - ) - - def test_good_credentials(self): - self.assertEqual( - 200, - self.app.get( - "/datafiles?limit=0", headers=self.good_credentials_header, - ).status_code, - ) - - -class TestQueriesRecords(TestCase): - def test_missing_record_error(self): - @queries_records - def raise_missing_record(): - raise MissingRecordError() - - with self.assertRaises(MissingRecordError) as ctx: - raise_missing_record() - self.assertEqual("No such record in table", str(ctx.exception)) - self.assertEqual(404, ctx.exception.status_code) - - def test_bad_filter_error(self): - @queries_records - def raise_bad_filter_error(): - raise FilterError() - - with self.assertRaises(FilterError) as ctx: - raise_bad_filter_error() - - self.assertEqual("Invalid filter requested", str(ctx.exception)) - self.assertEqual(400, ctx.exception.status_code) - - def test_value_error(self): - @queries_records - def raise_value_error(): - raise ValueError() - - with self.assertRaises(BadRequestError) as ctx: - raise_value_error() - - self.assertEqual("Bad request", str(ctx.exception)) - self.assertEqual(400, ctx.exception.status_code) - - def test_type_error(self): - @queries_records - def raise_type_error(): - raise TypeError() - - with self.assertRaises(BadRequestError) as ctx: - raise_type_error() - - self.assertEqual("Bad request", str(ctx.exception)) - self.assertEqual(400, ctx.exception.status_code) - - def test_integrity_error(self): - @queries_records - def raise_integrity_error(): - raise IntegrityError() - - with self.assertRaises(BadRequestError) as ctx: - raise_integrity_error() - - self.assertEqual("Bad request", str(ctx.exception)) - self.assertEqual(400, ctx.exception.status_code) - - def test_bad_request_error(self): - @queries_records - def raise_bad_request_error(): - raise BadRequestError() - - with self.assertRaises(BadRequestError) as ctx: - raise_bad_request_error() - - self.assertEqual("Bad request", str(ctx.exception)) - self.assertEqual(400, ctx.exception.status_code) - - -class TestGetSessionIDFromAuthHeader(FlaskAppTest): - def test_no_session_in_header(self): - with self.app: - self.app.get("/") - self.assertRaises(MissingCredentialsError, get_session_id_from_auth_header) - - def test_with_bad_header(self): - with self.app: - self.app.get("/", headers={"Authorization": "test"}) - self.assertRaises(AuthenticationError, get_session_id_from_auth_header) - - def test_with_good_header(self): - with self.app: - self.app.get("/", headers={"Authorization": "Bearer test"}) - self.assertEqual("test", get_session_id_from_auth_header()) - - -class TestGetFiltersFromQueryString(FlaskAppTest): - def test_no_filters(self): - with self.app: - self.app.get("/") - self.assertEqual([], get_filters_from_query_string()) - - def test_bad_filter(self): - with self.app: - self.app.get('/?test="test"') - self.assertRaises(FilterError, get_filters_from_query_string) - - def test_limit_filter(self): - with self.app: - self.app.get("/?limit=10") - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Returned incorrect number of filters", - ) - self.assertIs(LimitFilter, type(filters[0]), msg="Incorrect type of filter") - - def test_order_filter(self): - with self.app: - self.app.get('/?order="ID DESC"') - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Returned incorrect number of filters", - ) - self.assertIs( - OrderFilter, type(filters[0]), msg="Incorrect type of filter returned", - ) - - def test_where_filter(self): - with self.app: - self.app.get('/?where={"ID":{"eq":3}}') - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Returned incorrect number of filters", - ) - self.assertIs( - WhereFilter, type(filters[0]), msg="Incorrect type of filter returned", - ) - - def test_skip_filter(self): - with self.app: - self.app.get("/?skip=10") - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Returned incorrect number of filters", - ) - self.assertIs( - SkipFilter, type(filters[0]), msg="Incorrect type of filter returned", - ) - - def test_include_filter(self): - with self.app: - self.app.get('/?include="TEST"') - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Incorrect number of filters returned", - ) - self.assertIs( - IncludeFilter, - type(filters[0]), - msg="Incorrect type of filter returned", - ) - - def test_distinct_filter(self): - with self.app: - self.app.get('/?distinct="ID"') - filters = get_filters_from_query_string() - self.assertEqual( - 1, len(filters), msg="Incorrect number of filters returned", - ) - self.assertIs( - DistinctFieldFilter, - type(filters[0]), - msg="Incorrect type of filter returned", - ) - - def test_multiple_filters(self): - with self.app: - self.app.get("/?limit=10&skip=4") - filters = get_filters_from_query_string() - self.assertEqual(2, len(filters)) diff --git a/test/test_is_valid_json.py b/test/test_is_valid_json.py new file mode 100644 index 00000000..b83f3eec --- /dev/null +++ b/test/test_is_valid_json.py @@ -0,0 +1,34 @@ +import pytest + +from datagateway_api.common.helpers import is_valid_json + + +class TestIsValidJSON: + @pytest.mark.parametrize( + "input_json", + [ + pytest.param("[]", id="empty array"), + pytest.param("null", id="null"), + pytest.param('{"test":1}', id="key-value pair"), + pytest.param('{"test":{"inner_key":"inner_value"}}', id="nested json"), + ], + ) + def test_valid_json_input(self, input_json): + valid_json = is_valid_json(input_json) + + assert valid_json + + @pytest.mark.parametrize( + "invalid_json", + [ + pytest.param("{'test':1}", id="single quotes"), + pytest.param(None, id="none"), + pytest.param(1, id="integer"), + pytest.param({"test": 1}, id="dictionary"), + pytest.param([], id="empty list"), + ], + ) + def test_invalid_json_input(self, invalid_json): + valid_json = is_valid_json(invalid_json) + + assert not valid_json diff --git a/test/test_queries_records.py b/test/test_queries_records.py new file mode 100644 index 00000000..6879df45 --- /dev/null +++ b/test/test_queries_records.py @@ -0,0 +1,36 @@ +import pytest +from sqlalchemy.exc import IntegrityError + +from datagateway_api.common.exceptions import ( + BadRequestError, + FilterError, + MissingRecordError, +) +from datagateway_api.common.helpers import queries_records + + +class TestQueriesRecords: + @pytest.mark.parametrize( + "raised_exception, expected_exception, status_code", + [ + pytest.param(BadRequestError, BadRequestError, 400, id="bad request error"), + pytest.param(IntegrityError, BadRequestError, 400, id="integrity error"), + pytest.param(FilterError, FilterError, 400, id="invalid filter"), + pytest.param( + MissingRecordError, MissingRecordError, 404, id="missing record", + ), + pytest.param(TypeError, BadRequestError, 400, id="type error"), + pytest.param(ValueError, BadRequestError, 400, id="value error"), + ], + ) + def test_valid_error_raised( + self, raised_exception, expected_exception, status_code, + ): + @queries_records + def raise_exception(): + raise raised_exception() + + with pytest.raises(expected_exception) as ctx: + raise_exception() + + assert ctx.exception.status_code == status_code