diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4cebcf7 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +exclude = .venv *.json + diff --git a/.gitignore b/.gitignore index 68bc17f..124d5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ __pycache__/ *.py[cod] *$py.class - # C extensions *.so @@ -127,6 +126,9 @@ venv/ ENV/ env.bak/ venv.bak/ +commit_message.txt +./commit_message.txt + # Spyder project settings .spyderproject @@ -158,3 +160,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.DS_Store diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..379c507 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,41 @@ +[FORMAT] + +# Set the maximum line length to 100 characters +max-line-length=120 + +[MESSAGES CONTROL] +disable= + C0103, + C0116, + ; C0114, + C0115, + ; C0411, + R0801, + R0902, + R0903, + R0913, + R0914, + R1710, + W0102, + W0613, + W0621, + W0622, + W0718, + W3101 +# C0103: doesn't conform to snake_case naming style (invalid-name)<- missing just helper functions +# C0116: Missing function or method docstring +# C0114: Missing module docstring (missing-module-docstring) +# C0115: Missing class docstring +# C0411: Wrong import order %s +# R0801: Similar lines in %s files +# R0902: Too many instance attributes +# R0903: Too few public methods +# R0913: Too many arguments +# R0914: Too many local variables +# R1710: Either all return statements in a function should return an expression, or none of them should. +# W0102: Dangerous default value %s as argument +# W0613: Unused argument +# W0621: Redefining name %r from outer scope (line %s) +# W0622: Redefining built-in 'foo' (redefined-builtin) +# W0718: Catching too general exception Exception +# W3101: Missing timmeout can expose you to a DOS attack \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d99f2f3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2f5f4..d436b19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,82 @@ -Contributing guidelines -======================= +# Contributing guidelines -### Git workflow +## Git workflow -* Use git-flow - create a feature branch from `develop`, e.g. `feature/new-feature` +* Use git-flow - create a feature branch from `develop`, e.g. `feat/new-feature` * Pull requests must contain a succinct, clear summary of what the user need is driving this feature change * Ensure your branch contains logical atomic commits before sending a pull request * You may rebase your branch after feedback if it's to include relevant updates from the develop branch. It is preferable to rebase here then a merge commit as a clean and straight history on develop with discrete merge commits for features is preferred * To find out more about contributing click [here](https://contributing.md/) + +## Branching +* Branches should be named in the following format: + ```textbox + / + ``` + where `type` is one of the following: + ```textbox + * fix: for a bug fix + * feat: for a new feature + * docs: for documentation changes + * style: for changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) + * refactor: for refactoring production code + * test: for adding tests + * chore: for updating build tasks, package manager configs, etc + * build: for changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) + ``` + and `description` is a short description of the feature or fix being made + * e.g. `feat/new-feature` or `fix/bug-fix` +* Branches should be created from the `develop` branch +* Branches should be merged back into the `develop` branch + +## Commit messages + +Please use the following format for commit messages: + +```textbox +* fix: for a bug fix +* feat: for a new feature +* docs: for documentation changes +* style: for changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +* refactor: for refactoring production code +* test: for adding tests +* chore: for updating build tasks, package manager configs, etc +* build: for changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) +``` + +## Pull requests + +* Pull requests should be made to the `develop` branch +* Pull requests should be made from a feature branch, not `develop` +* Pull requests should be made with a succinct, clear summary of what the user need is driving this feature change +* An automated template will be provided to help with this process, please fill it out as best you can + +## Release branches and Tagging + +* Once a single or set of changes has been made that can be released, we create a release branch off develop + * Releases should be as small as possible so that it is easier to fix issues and rollback code in production environment without losing many features at once + * Release branches should be of the following structure: + + ```sh + release/0.1.0 + ``` + + * The release name should abide by [semantic versioning (semver)](https://semver.org/) +* Once release branch has been created, a pull request should be made against the `main` branch +* Once approved, the release branch can be merged locally :warning: **WARNING - Do not push to remote** :warning: +* Tag the release and push to remote - [see tagging](#tagging) + +## Tagging + +* Tag latest commit on branch, this should be the local merge commit + * Check latest local commit, run: `git show HEAD` + * Create tag, run: `tag -s -m ` + * where `tag` is the semver value (should be taken from the release branch name) and `` is a general, short and concise message of the change +* Push release merge and tag to remote, run: `git push --follow-tags` +* Once pushed, go to the Github releases page for the repository in question, which can be found by clicking on the <> Code tab and clicking on Releases on the right hand side: + * Select Tags + * Click on the v0.1.0 tag that you just created + * Choose Create release from tag + * Copy the release name and make human-friendly (capitalise, remove /) to create a release title (e.g. release/0.1.0 -> Release 0.1.0) + * Add relevant release notes, this can be expanded from initial message created against the tag. Advised to use bullet points to list out changes + * Hit Publish release diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6d05e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,99 @@ +.ONESHELL: + +.DEFAULT_GOAL := run + +PYTHON = ./.venv/bin/python3 +PIP = ./.venv/bin/pip + + +.PHONY: help auth clean dbclean mongostart mongostop run setup swag test test-integration + +help: + @echo "make help - display this help" + @echo "make auth - run auth api application" + @echo "make build - create and activate virtual environment" + @echo "make clean - remove all generated files" + @echo "make dbclean - clear the application database" + @echo "make mongostart - start local mongodb instance" + @echo "make mongostop - stop local mongodb instance" + @echo "make run - run the application" + @echo "make swag - open swagger documentation" + @echo "make setup - setup the application database" + @echo "make test - run tests and coverage report" + @echo "make test-integration - run integration tests in test environment" + @echo "make helptools - display help for tools" + +auth: + $(PYTHON) ../tdse-accessForce-auth-stub/app.py + +build: requirements.txt + python3 -m venv .venv + $(PIP) install -r requirements.txt + . ./.venv/bin/activate + +clean: + @echo "Cleaning up..." + @find . -name "__pycache__" -type d -exec rm -rf {} + + @find . -name ".pytest_cache" -exec rm -rf {} + + @find . -name ".venv" -exec rm -rf {} + + +dbclean: + @echo "Cleaning up database..." + cd ./scripts/; \ + make dbclean + @echo "Database cleared." + +mongostart: + @echo "Starting MongoDB..." + brew services start mongodb-community@6.0 + +mongostop: + @echo "Stopping MongoDB..." + brew services stop mongodb-community@6.0 + +run: build + $(PYTHON) app.py + +setup: + @echo "Setting up application database..." + cd ./scripts/; \ + make dbclean; \ + make bids; \ + make questions + @echo "Database setup complete." + +swag: + open http://localhost:8080/api/docs/#/ + +test: + -coverage run -m pytest tests/unit -vv -s + @echo "TEST COVERAGE REPORT" + coverage report -m --omit="app.py,tests/*,dbconfig/*,custom_formatter.py,conftest.py" + +test-integration: + pytest tests/integration -vv -s + + +.PHONY: helptools authplay branch check commit format lint + +helptools: + make -f tools.mk help + +authplay: + cd ./scripts/; \ + make authplay + +branch: + make -f tools.mk branch + +check: + make -f tools.mk check + +commit: + make -f tools.mk commit + +format: + make -f tools.mk format + +lint: + make -f tools.mk lint diff --git a/README.md b/README.md index 9087fc4..39a2554 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,174 @@ # tdse-accessForce-bids-api -Bids API training project with Python and MongoDB +# API Documentation -# Bid Library +This API stores and serves information about Methods bids for client tenders. -## Contents +## Prerequisites -- [Background](#background) - - [Before working on a bid](#before-working-on-a-bid) - - [Bid phases](#phases) -- [Brief](#brief) - - [Acceptance Criteria](#acceptance-criteria) - - [Iterations](#iterations) +- Python 3.x +- Flask +- Homebrew +- Makefile -## Background +## Running the API -Methods being a consultancy agency to win new work we make to make bids on client tenders. +1. Run the following command to start the API: -- A tender is a piece of work that an organisation (potential client) needs an external team to work on or to supplement an existing team -- A bid can comprise of several stages to win a tender, usually there are two phases which comprise of phase 1 and phase 2 + ```bash + make run + ``` + * The API will be available at http://localhost:8080/api/bids -### Before working on a bid +2. To see all available Make targets, run the following command in a new terminal: -Before phase 1, there is time for bidders like Methods to ask questions of the client tender. These questions are open to all those looking to bid on the tender and all questions and answers are available to all bidders on a single tender. + ```bash + make help + ``` +3. Follow this link to go to the authorization documentation: [Authorization Documentation](https://github.com/methods/tdse-accessForce-auth-stub/blob/main/README.md) -This step is a necessary is really important for Methods to understand whether we really want to bid on a particular tender. Some considerations before bidding: +4. In a new terminal enter the following command to run authorization server if not already running. This will be needed to generate a token: -- Do we like the project and hence want it? -- Is it good value? -- Will this get us known with a new client and give Methods leverage in future work? -- Is the tender something different that would expand our portfolio? -- Can we do it? + ```bash + make auth + ``` + * The API will be available at http://localhost:5000/authorise -### Phases - -**Phase 1** compromises of a list of questions set by the tender; in government the scoring system for each question is out of 3, so if there are 6 questions a bidder can achieve a maximum score of 18. The answers to the questions usually have a word limit of 100 or 200 (in this phase). - -The client that puts out a tender decides the pass rate in phase 1 to progress to phase 2. The pass rate may not be known until results of all bids are completed for each of the bidders. The list below are common pass criteria you might come across on a bid (remember this can vary from client to client and even within multiple tenders by the same client): - -- All questions must have a score greater than 1, (so 2 minimum) -- A minimum overall score, e.g. 14 out of 18 -- The 3 highest bids assuming the bids met criteria 1 or/and 2 - -**Phase 2** is a lot more involved than phase 1, it can comprise of a face to face (or virtual) presentation alongside answers to questions limited to x number of words (limit set by client). These questions will cover team culture and technical solution. +-------------- -There are usually 3 categories that Methods are scored on and these are weighted by the client. The categories are: +## Environmental Variables -- Technical - questions from presentation or form and results from phase 1 -- Culture - questions in phase 2 -- Cost (a.k.a. Rate) +In order to validate your credentials, configure the database connection and utilise pagination you will have to set up the environmental variables locally. -The overall score is worked out as a percentage, (out of 100). Whichever bidder scores highest wins the bid and that is the end of the tendering process. +To do this, create a `.env` file in your root folder, with the following key/value pairs: -Next steps: Statement of Work (SoW, the contract) is put together by the client, handing over CVs of potential staff Methods are going to supply and agreement on project start dates. + API_KEY=THIS_IS_THE_API_KEY + SECRET_KEY=THIS_IS_A_SECRET + DB_HOST=localhost + DB_NAME=bidsAPI + TEST_DB_NAME=testAPI + DEFAULT_OFFSET=0 + DEFAULT_LIMIT=20 + MAX_OFFSET=2000 + MAX_LIMIT=1000 + DEFAULT_SORT_BIDS=bid_date + DEFAULT_SORT_QUESTIONS=description + APP_NAME=BidsAPI + APP_VERSION=0.8.0 + APP_LANG=Python -------------- -## Brief - -Currently Methods store all the information for tenders and bids in Sharepoint, the way the documents are stored and the information available can vary quite a lot making it hard for the bid team to find good answers to questions and successful bids for reuse. We currently do not store who helped answering questions against a bid and in some cases where Methods have done so it only informs us of their initials. +## Installing and running an instance of MongoDB on your local machine (MacOS) -What intend to build is an API that can store tender/bid information in a structured way to facilitate finding successful bids and high scoring questions. +### To install on Windows please see [here](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-windows/) -### Acceptance Criteria +1. Install MongoDB by running the following commands: -**Must** have: + ```bash + brew tap mongodb/brew + brew install mongodb-community + ``` +2. To run MongoDB (i.e. the mongod process) as a macOS service, run: -1. Ability to access all bid data, see list of data below: + ```bash + make mongostart + ``` +3. To verify that MongoDB is running, run: - - tender title - - tender short description (problem statement) - - client - - date of the tender - - were Methods successful - - what phase did we get to - - how well we did in each of the phases - - any technologies or skills reuired by client tender - - tender questions and Methods answers and the respective scores - - who helped answer a question - - provide links to further information on bids stored in sharepoint - - when was the data last updated - - any skills or technologies listed in the answers to questions + ```bash + brew services list + ``` + You should see the service `mongodb-community` listed as `started`. +4. Run the following command to stop the MongoDB instance, as needed: -1. Ability to find any bid -1. Ability to add new bids -1. Ability to update a bid that is still in progress -1. Ability to delete a bid and associated -1. Ability to recover deleted bid data within 4 weeks of deletion -1. Ability to filter bids and questions based on success and score -1. Ability to sort bids and questions alphanumerically and page through the results -1. Ability to secure access to changing the data to certain users + ```bash + make mongostop + ``` +5. To begin using MongoDB, connect the MongoDB shell (mongosh) to the running instance. From a new terminal, issue the following: -**Should** have: + ```bash + mongosh + ``` +6. To create a new database called `bidsAPI`, run: -1. Ability to search for bids containing particular text -1. Ability to search for questions containing particular text + ```bash + use bidsAPI + ``` +7. To create a new test database called `testAPI`, run: -**Could** have: + ```bash + use testAPI + ``` +8. To exit the MongoDB shell, run the following command: -1. Ability to control different user access (permissions) based on roles + ```bash + exit + ``` +OPTIONAL - Download MongoDB Compass to view the database in a GUI. You can download it from [here](https://www.mongodb.com/try/download/compass) - - Admin - - Bid writers - - Bid viewers - -1. Ability to access this software anywhere in the UK +-------------- -**Would not** have: +## Database Setup -1. Due to size of some answers and the content not being soley text but images, diagrams etc. Methods does not wish to duplicate this information from Sharepoint into a filesystem like AWS S3 or Azure Storage +To set up the application database, run the following command: -### Iterations + ```bash + make setup + ``` -**Iteration 1** Build API and initial storage system to find, add, update and remove. Steps 1 to 8 from Must section +This will perform the following steps: -**Iteration 2** Secure the API to users who need access, based on the "Principle least priviledge principle. Step 9 from Must section +1. Clean up the existing database +2. Populate the bids collection with dummy data +3. Populate the questions collection with dummy data, using existing bid IDs -**Iteration 3** Build search engine to allow for a more sophisticated way of finding questions and bids related to your needs. Steps 1 and 2 from Should section +-------------- -**Iteration 4** Expand on access control to bid library based on roles, users and teams where necessary. Step 1 of Could section +## Accessing API Documentation (OAS) -**Iteration 5** Host the bid library to be accessed by users across the country. Step 2 of Could section +1. Run the following command to start the API (if you haven't already): -**Iteration 6** Build a web app to integrate with the bids API, create user journeys that allow users to find, add and update bid content + ```bash + make run + ``` +2. In a new terminal run the following command to view the Swagger UI in your default web browser: + + ```bash + make swag + ``` +-------------- +## Testing the application + +1. Follow the steps above to start the API, authorization server and database connection (if you haven't already). + +2. Run the following command to setup the test database: + + ```bash + make test-setup + ``` +3. Enter the following command to run the test suites and generate a test coverage report: + + ```bash + make test + ``` +4. Enter the following command to run the integration tests: + + ```bash + make test-integration + ``` -------------- -**Note:** If this is part of your training, you should look for guidance by your mentor of how to progress this project. Your coach can use the [generic project rest API doc](/training/generic-projects/rest-api/README.md) to setup your initial project that will cover iteration 1. +## Using auth playground to generate a token and make authenticated requests to the Bids API --------------- +1. Follow the steps above to start the API, authorization server and database connection (if you haven't already). + +2. In a new terminal enter the following command to open the auth playground in your default web browser: -Return to the [internal projects](https://github.com/methods/tdse-projects/blob/main/internal/README.md) for additional options and information. + ```bash + make authplay + ``` +3. Follow the steps in the auth playground to generate a token and much more. -------------- diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/controllers/__init__.py b/api/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/controllers/bid_controller.py b/api/controllers/bid_controller.py new file mode 100644 index 0000000..86140ed --- /dev/null +++ b/api/controllers/bid_controller.py @@ -0,0 +1,220 @@ +""" +This module implements the bid controller. +""" +import logging +from datetime import datetime +from flask import Blueprint, current_app, g, jsonify, request +from marshmallow import ValidationError +from werkzeug.exceptions import NotFound, UnprocessableEntity +from api.models.status_enum import Status +from helpers.helpers import ( + showInternalServerError, + showNotFoundError, + showUnprocessableEntityError, + showValidationError, + validate_and_create_bid_document, + validate_id_path, + validate_bid_update, + validate_status_update, + prepend_host_to_links, + require_api_key, + require_jwt, + require_admin_access, + validate_sort, + validate_pagination, +) + +bid = Blueprint("bid", __name__) +logger = logging.getLogger() + + +@bid.route("/bids", methods=["GET"]) +@require_api_key +def get_bids(): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + hostname = request.headers.get("host") + field, order = validate_sort(request.args.get("sort"), "bids") + limit, offset = validate_pagination( + request.args.get("limit"), request.args.get("offset") + ) + + # Prepare query filter and options + query_filter = {"status": {"$ne": Status.DELETED.value}} + query_options = {"sort": [(field, order)], "skip": offset, "limit": limit} + + # Fetch data and count documents + data = list(db["bids"].find(query_filter, **query_options)) + total_count = db["bids"].count_documents(query_filter) + + for resource in data: + prepend_host_to_links(resource, hostname) + + return { + "count": len(data), + "total_count": total_count, + "limit": limit, + "offset": offset, + "items": data, + }, 200 + except ValueError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return jsonify({"Error": str(error)}), 400 + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@bid.route("/bids", methods=["POST"]) +@require_jwt +def post_bid(): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + # Process input and create data model + data = validate_and_create_bid_document(request.get_json()) + # Insert document into database collection + db["bids"].insert_one(data) + return data, 201 + # Return 400 response if input validation fails + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@bid.route("/bids/", methods=["GET"]) +@require_api_key +def get_bid_by_id(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + data = db["bids"].find_one( + {"_id": bid_id, "status": {"$ne": Status.DELETED.value}} + ) + if not data: + raise NotFound("Resource not found") + # Get hostname from request headers + hostname = request.headers.get("host") + # print(data, hostname) + data = prepend_host_to_links(data, hostname) + return data, 200 + # Return 404 response if not found / returns None + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + # Return 400 if bid_id is invalid + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@bid.route("/bids/", methods=["PUT"]) +@require_jwt +def update_bid_by_id(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + # Retrieve resource where id is equal to bid_id + current_bid = db["bids"].find_one( + {"_id": bid_id, "status": Status.IN_PROGRESS.value} + ) + # Return 404 response if not found / returns None + if not current_bid: + raise NotFound("Resource not found") + updated_bid = validate_bid_update(request.get_json(), current_bid) + db["bids"].replace_one( + {"_id": bid_id}, + updated_bid, + ) + return updated_bid, 200 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + # Return 400 response if input validation fails + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except UnprocessableEntity as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnprocessableEntityError(error), 422 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@bid.route("/bids/", methods=["DELETE"]) +@require_admin_access +def change_status_to_deleted(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + data = db["bids"].find_one_and_update( + {"_id": bid_id, "status": {"$ne": Status.DELETED.value}}, + { + "$set": { + "status": Status.DELETED.value, + "last_updated": datetime.now().isoformat(), + } + }, + ) + if not data: + raise NotFound("Resource not found") + return data, 204 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + # Return 400 response if input validation fails + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@bid.route("/bids//status", methods=["PUT"]) +@require_admin_access +def update_bid_status(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + # Retrieve resource where id is equal to bid_id + current_bid = db["bids"].find_one({"_id": bid_id}) + # Return 404 response if not found / returns None + if current_bid is None: + raise NotFound("Resource not found") + updated_bid = validate_status_update(request.get_json(), current_bid) + db["bids"].replace_one( + {"_id": bid_id}, + updated_bid, + ) + return updated_bid, 200 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + # Return 400 response if input validation fails + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except UnprocessableEntity as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnprocessableEntityError(error), 422 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 diff --git a/api/controllers/question_controller.py b/api/controllers/question_controller.py new file mode 100644 index 0000000..68084cd --- /dev/null +++ b/api/controllers/question_controller.py @@ -0,0 +1,191 @@ +""" +This module implements the Question Controller blueprint. +""" +import logging +from flask import Blueprint, current_app, g, request, jsonify +from marshmallow import ValidationError +from werkzeug.exceptions import NotFound, UnprocessableEntity +from api.models.status_enum import Status +from helpers.helpers import ( + showInternalServerError, + showNotFoundError, + showUnprocessableEntityError, + showValidationError, + validate_and_create_question_document, + validate_id_path, + validate_question_update, + prepend_host_to_links, + require_api_key, + require_jwt, + require_admin_access, + validate_pagination, + validate_sort, +) + +question = Blueprint("question", __name__) +logger = logging.getLogger() + + +@question.route("/bids//questions", methods=["POST"]) +@require_jwt +def post_question(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + # Check if the bid exists in the database + bid = db["bids"].find_one({"_id": bid_id}) + if not bid: + raise NotFound("Resource not found") + # Process input and create data model + data = validate_and_create_question_document(request.get_json(), bid_id) + # Insert document into database collection + db["questions"].insert_one(data) + return data, 201 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + # Return 400 response if input validation fails + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + # Return 500 response in case of connection failure + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@question.route("/bids//questions", methods=["GET"]) +@require_api_key +def get_questions(bid_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + hostname = request.headers.get("host") + field, order = validate_sort(request.args.get("sort"), "questions") + limit, offset = validate_pagination( + request.args.get("limit"), request.args.get("offset") + ) + # Prepare query filter and options + query_filter = { + "status": {"$ne": Status.DELETED.value}, + "links.bid": f"/api/bids/{bid_id}", + } + query_options = {"sort": [(field, order)], "skip": offset, "limit": limit} + + # Fetch data and count documents + data = list(db["questions"].find(query_filter, **query_options)) + total_count = db["questions"].count_documents(query_filter) + + if not data: + raise NotFound("Resource not found") + for question in data: + prepend_host_to_links(question, hostname) + return { + "total_count": total_count, + "count": len(data), + "offset": offset, + "limit": limit, + "items": data, + }, 200 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except ValueError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return jsonify({"Error": str(error)}), 400 + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@question.route("/bids//questions/", methods=["GET"]) +@require_api_key +def get_question(bid_id, question_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + question_id = validate_id_path(question_id) + hostname = request.headers.get("host") + data = db["questions"].find_one( + { + "_id": question_id, + "links.self": f"/api/bids/{bid_id}/questions/{question_id}", + "status": {"$ne": Status.DELETED.value}, + } + ) + if not data: + raise NotFound("Resource not found") + prepend_host_to_links(data, hostname) + return data, 200 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@question.route("/bids//questions/", methods=["DELETE"]) +@require_admin_access +def delete_question(bid_id, question_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + question_id = validate_id_path(question_id) + bid = db["bids"].find_one({"_id": bid_id}) + if not bid: + raise NotFound("Resource not found") + data = db["questions"].delete_one({"_id": question_id}) + return data.raw_result, 204 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 + + +@question.route("/bids//questions/", methods=["PUT"]) +@require_jwt +def update_question(bid_id, question_id): + try: + db = current_app.db + logger.info(f"Handling request {g.request_id}") + bid_id = validate_id_path(bid_id) + question_id = validate_id_path(question_id) + data = db["questions"].find_one( + { + "_id": question_id, + "links.self": f"/api/bids/{bid_id}/questions/{question_id}", + } + ) + if not data: + raise NotFound("Resource not found") + updated_question = validate_question_update(request.get_json(), data) + db["questions"].replace_one({"_id": question_id}, updated_question) + return updated_question, 200 + except NotFound: + logger.error(f"{g.request_id} failed", exc_info=True) + return showNotFoundError(), 404 + except ValidationError as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showValidationError(error), 400 + except UnprocessableEntity as error: + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnprocessableEntityError(error), 422 + except Exception: + logger.error(f"{g.request_id} failed", exc_info=True) + return showInternalServerError(), 500 diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/bid_model.py b/api/models/bid_model.py new file mode 100644 index 0000000..55b0081 --- /dev/null +++ b/api/models/bid_model.py @@ -0,0 +1,76 @@ +""" +This module contains the data model for the bid resource. +""" +from uuid import uuid4 +from datetime import datetime +from api.models.status_enum import Status + + +# Data model for bid resource +class BidModel: + """ + Represents a bid model for the MongoDB database. + + Attributes: + tender (str): The tender for which the bid is submitted. + client (str): The client for whom the bid is prepared. + bid_date (str): The date when the bid was submitted (in ISO format). + alias (str): An alias or abbreviation for the client. + bid_folder_url (str): The URL to the bid's folder in the organization's SharePoint. + feedback (dict): A dictionary containing feedback information. + success (list): A list of dictionaries representing successful phases of the bid. + failed (dict): A dictionary representing the failed phase of the bid. + links (dict): A dictionary containing links to the bid resource and questions resource. + last_updated (str): The date and time when the bid was last updated (in ISO format). + """ + + def __init__( + self, + tender, + client, + bid_date, + alias=None, + bid_folder_url=None, + feedback=None, + failed=None, + was_successful=False, + success=[], + status=None, + _id=None, + links=None, + last_updated=None, + ): + if _id is None: + self._id = uuid4() + else: + self._id = _id + if status is None: + self.status = Status.IN_PROGRESS + else: + self.status = status + self.tender = tender + self.client = client + self.alias = alias + self.bid_date = bid_date + self.bid_folder_url = bid_folder_url + self.links = LinksModel(self._id) + self.was_successful = was_successful + self.success = success + self.failed = failed + self.feedback = feedback + self.last_updated = datetime.now() + + +# Model for links object +class LinksModel: + """ + Represents a links model for the bid resource. + + Attributes: + self (str): The URL to the bid resource. + questions (str): The URL to the questions resource related to the bid. + """ + + def __init__(self, bid_id): + self.self = f"/api/bids/{bid_id}" + self.questions = f"/api/bids/{bid_id}/questions" diff --git a/api/models/question_model.py b/api/models/question_model.py new file mode 100644 index 0000000..89c3038 --- /dev/null +++ b/api/models/question_model.py @@ -0,0 +1,61 @@ +""" +This module contains the data model for the question resource. +""" +from uuid import uuid4 +from datetime import datetime +from api.models.status_enum import Status + + +# Data model for question resource +class QuestionModel: + def __init__( + self, + description, + question_url, + feedback, + bid_id=None, + response=None, + score=None, + out_of=None, + respondents=[], + status=None, + links=None, + last_updated=None, + _id=None, + ): + if _id is None: + self._id = uuid4() + else: + self._id = _id + if status is None: + self.status = Status.IN_PROGRESS + else: + self.status = status + self.description = description + self.question_url = question_url + self.feedback = feedback + self.response = response + self.score = score + self.out_of = out_of + self.respondents = respondents + if bid_id is None: + self.links = links + else: + self.links = LinksModel(self._id, bid_id) + + self.last_updated = datetime.now() + + +# Model for links object +class LinksModel: + """ + Represents a links model for the question resource. + + Attributes: + self (str): The URL to the question resource. + questions (str): The URL to the bid resource related to the question. + """ + + def __init__(self, question_id, bid_id): + self.self = f"/api/bids/{bid_id}/questions/{question_id}" + self.bid = f"/api/bids/{bid_id}" diff --git a/api/models/status_enum.py b/api/models/status_enum.py new file mode 100644 index 0000000..402d910 --- /dev/null +++ b/api/models/status_enum.py @@ -0,0 +1,22 @@ +""" +This module contains the Status enum. +""" +from enum import Enum, unique + + +@unique +class Status(Enum): + """ + Enumeration representing the status of a bid. + + Each status value represents a different state of a bid in the system. + + Enum Values: + DELETED (str): The bid has been deleted. + IN_PROGRESS (str): The bid is currently in progress. + COMPLETED (str): The bid has been completed. + """ + + DELETED = "deleted" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/schemas/bid_links_schema.py b/api/schemas/bid_links_schema.py new file mode 100644 index 0000000..78ea041 --- /dev/null +++ b/api/schemas/bid_links_schema.py @@ -0,0 +1,18 @@ +""" +This module contains the schema for representing links in a bid resource. +""" + +from marshmallow import Schema, fields + + +class BidLinksSchema(Schema): + """ + Schema for representing links in a bid resource. + + Attributes: + self (str): The URL to the bid resource. + questions (str): The URL to the questions resource related to the bid. + """ + + self = fields.Str(required=True) + questions = fields.Str(required=True) diff --git a/api/schemas/bid_schema.py b/api/schemas/bid_schema.py new file mode 100644 index 0000000..d61981f --- /dev/null +++ b/api/schemas/bid_schema.py @@ -0,0 +1,77 @@ +""" +This module contains the BidSchema class, which is a Marshmallow schema for the bid object. +""" + +from marshmallow import Schema, fields, post_load, validates_schema, ValidationError +from api.models.status_enum import Status +from api.models.bid_model import BidModel +from .bid_links_schema import BidLinksSchema +from .phase_schema import PhaseSchema +from .feedback_schema import FeedbackSchema + + +# Marshmallow schema for bid resource +class BidSchema(Schema): + """ + Marshmallow schema for the bid object. + + Attributes: + _id (UUID): The unique identifier of the bid. + tender (str): The tender for which the bid is submitted. + client (str): The client for whom the bid is prepared. + bid_date (Date): The date when the bid was submitted (in the format "%Y-%m-%d"). + alias (str, optional): An alias or abbreviation for the client. + bid_folder_url (str, optional): The URL to the bid's folder in the organization's SharePoint. + was_successful (bool, optional): Whether the bid was successful or not. + success (list of PhaseSchema, optional): A list of successful phases of the bid. + failed (PhaseSchema, optional): The failed phase of the bid. + feedback (FeedbackSchema, optional): Feedback information for the bid. + status (Status): The status of the bid (using the Status enum). + links (BidLinksSchema): Links to the bid resource and questions resource. + last_updated (DateTime, optional): The date and time when the bid was last updated. + """ + + _id = fields.UUID() + tender = fields.Str( + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + client = fields.Str( + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + bid_date = fields.Date( + format="%Y-%m-%d", + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + alias = fields.Str(allow_none=True) + bid_folder_url = fields.URL(allow_none=True) + was_successful = fields.Boolean(allow_none=True) + success = fields.List(fields.Nested(PhaseSchema), allow_none=True) + failed = fields.Nested(PhaseSchema, allow_none=True) + feedback = fields.Nested(FeedbackSchema, allow_none=True) + status = fields.Enum(Status, by_value=True) + links = fields.Nested(BidLinksSchema) + last_updated = fields.DateTime() + + @validates_schema + def validate_unique_phases(self, data, **kwargs): + # Get the list of success phases and the failed phase (if available) + success_phases = data.get("success", []) + failed_phase = data.get("failed", None) + + # Combine the success phases and the failed phase (if available) + all_phases = success_phases + ([failed_phase] if failed_phase else []) + + # Extract phase values and remove any None values + phase_values = [phase.get("phase") for phase in all_phases if phase] + + # Check if phase_values contain duplicates using sets + if len(phase_values) != len(set(phase_values)): + raise ValidationError("Phase values must be unique") + + # Creates a Bid instance after processing + @post_load + def makeBid(self, data, **kwargs): + return BidModel(**data) diff --git a/api/schemas/feedback_schema.py b/api/schemas/feedback_schema.py new file mode 100644 index 0000000..459dd47 --- /dev/null +++ b/api/schemas/feedback_schema.py @@ -0,0 +1,18 @@ +""" +This module contains the schema for the feedback data in a bid. +""" + +from marshmallow import Schema, fields + + +class FeedbackSchema(Schema): + """ + Schema for the feedback data in a bid. + + Attributes: + description (str): The description of the feedback. + url (str): The URL of the feedback. + """ + + description = fields.Str(required=True) + url = fields.URL(required=True) diff --git a/api/schemas/id_schema.py b/api/schemas/id_schema.py new file mode 100644 index 0000000..712ecac --- /dev/null +++ b/api/schemas/id_schema.py @@ -0,0 +1,17 @@ +""" +This module contains the schema for validating path param IDs. +""" +from marshmallow import Schema, fields, validate + + +class IdSchema(Schema): + """ + Schema for validating path param IDs. + + Attributes: + id (str): The bid ID to be validated. + """ + + id = fields.Str( + required=True, validate=validate.Length(equal=36, error="Invalid Id") + ) diff --git a/api/schemas/phase_schema.py b/api/schemas/phase_schema.py new file mode 100644 index 0000000..9c7a1dd --- /dev/null +++ b/api/schemas/phase_schema.py @@ -0,0 +1,46 @@ +""" +This module contains the schema for representing a bid phase. +""" +from enum import Enum, unique +from marshmallow import Schema, fields, validates_schema, ValidationError + + +@unique +class Phase(Enum): + """ + Enum representing phases of a bid. + + Attributes: + PHASE_1 (int): Phase 1 of the bid. + PHASE_2 (int): Phase 2 of the bid. + """ + + PHASE_1 = 1 + PHASE_2 = 2 + + +class PhaseSchema(Schema): + """ + Schema for representing a bid phase. + + Attributes: + phase (Phase): The phase of the bid. + has_score (bool): Indicates if the phase has a score. + score (int): The score of the phase (if applicable). + out_of (int): The maximum score possible for the phase (if applicable). + """ + + phase = fields.Enum(Phase, required=True, by_value=True) + has_score = fields.Boolean(required=True) + score = fields.Integer() + out_of = fields.Integer() + + @validates_schema + def validate_score(self, data, **kwargs): + if data["has_score"] is True and "score" not in data: + raise ValidationError("Score is mandatory when has_score is set to true.") + + @validates_schema + def validate_out_of(self, data, **kwargs): + if data["has_score"] is True and "out_of" not in data: + raise ValidationError("Out_of is mandatory when has_score is set to true.") diff --git a/api/schemas/question_links_schema.py b/api/schemas/question_links_schema.py new file mode 100644 index 0000000..dc9c747 --- /dev/null +++ b/api/schemas/question_links_schema.py @@ -0,0 +1,17 @@ +""" +This module contains the schema for representing links in a question resource. +""" +from marshmallow import Schema, fields + + +class QuestionLinksSchema(Schema): + """ + Schema for representing links in a question resource. + + Attributes: + self (str): The URL to the question resource. + questions (str): The URL to the bid resource related to the question. + """ + + self = fields.Str(required=True) + bid = fields.Str(required=True) diff --git a/api/schemas/question_schema.py b/api/schemas/question_schema.py new file mode 100644 index 0000000..5e20203 --- /dev/null +++ b/api/schemas/question_schema.py @@ -0,0 +1,39 @@ +""" +This module contains the marshmallow schema for the question resource. +""" +from marshmallow import Schema, fields, post_load +from api.models.status_enum import Status +from api.models.question_model import QuestionModel +from .question_links_schema import QuestionLinksSchema +from .feedback_schema import FeedbackSchema + + +# Marshmallow schema for question resource +class QuestionSchema(Schema): + _id = fields.UUID() + description = fields.Str( + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + question_url = fields.URL( + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + feedback = fields.Nested( + FeedbackSchema, + required=True, + error_messages={"required": {"message": "Missing mandatory field"}}, + ) + bid_id = fields.UUID() + response = fields.Str(allow_none=True) + score = fields.Integer(allow_none=True) + out_of = fields.Integer(allow_none=True) + respondents = fields.List(fields.Str(allow_none=True)) + status = fields.Enum(Status, by_value=True) + links = fields.Nested(QuestionLinksSchema) + last_updated = fields.DateTime() + + # Creates a Question instance after processing + @post_load + def makeQuestion(self, data, **kwargs): + return QuestionModel(**data) diff --git a/app.py b/app.py new file mode 100644 index 0000000..d3f252b --- /dev/null +++ b/app.py @@ -0,0 +1,78 @@ +""" +This is a simple Python application. + +""" + +import json +import logging +import logging.config +import os +import uuid +from dotenv import load_dotenv +from flask import Flask, g, request +from flask_swagger_ui import get_swaggerui_blueprint +from api.controllers.bid_controller import bid +from api.controllers.question_controller import question +from dbconfig.mongo_setup import get_db + + +load_dotenv() + + +# Load the configuration from the JSON file +with open("logconfig/logging_config.json", "r") as f: + config = json.load(f) + +# Configure the logger using dictConfig +logging.config.dictConfig(config) +logger = logging.getLogger() + + +# Swagger config +SWAGGER_URL = "/api/docs" # URL for exposing Swagger UI +API_URL = "/static/swagger_config.yml" # Our API url + +# Call factory function to create our blueprint +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + API_URL, + config={"app_name": "Bids API Swagger"}, +) + + +# App factory function +def create_app(): + # Create the Flask application + app = Flask(__name__) + + # Configure the Flask application + config_type = os.getenv("CONFIG_TYPE", default="config.DevelopmentConfig") + app.config.from_object(config_type) + + # Register blueprints + app.register_blueprint(swaggerui_blueprint) + app.register_blueprint(bid, url_prefix="/api") + app.register_blueprint(question, url_prefix="/api") + + # Create db client instance in application context + with app.app_context(): + db = get_db(app.config["DB_HOST"], app.config["DB_PORT"], app.config["DB_NAME"]) + app.db = db + + # Custom middleware to log request info + def log_request_info(): + request_id = uuid.uuid4() + g.request_id = request_id + logger.info( + f"New request {g.request_id}: {request.method} {request.url} - - {request.endpoint}" + ) + + app.before_request(log_request_info) + + return app + + +if __name__ == "__main__": + logger.debug("Starting application") + app = create_app() + app.run(port=8080) diff --git a/config.py b/config.py new file mode 100644 index 0000000..86233ec --- /dev/null +++ b/config.py @@ -0,0 +1,19 @@ +import os + + +class Config(object): + FLASK_ENV = "development" + DEBUG = False + TESTING = False + DB_HOST = os.getenv("DB_HOST") + DB_PORT = 27017 + DB_NAME = os.getenv("DB_NAME") + + +class DevelopmentConfig(Config): + DEBUG = True + + +class TestingConfig(Config): + TESTING = True + DB_NAME = os.getenv("TEST_DB_NAME") diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..ec26ed8 --- /dev/null +++ b/conftest.py @@ -0,0 +1,146 @@ +""" +This file contains fixtures that are used by multiple tests. +""" + +import json +import jwt +import logging +import os +import pytest +from app import create_app +from dotenv import load_dotenv + +load_dotenv() + +with open("./tests/integration/bids.json") as bids: + bids_data = json.load(bids) + +with open("./tests/integration/questions.json") as questions: + questions_data = json.load(questions) + + +@pytest.fixture(scope="session") +def test_app(): + os.environ["CONFIG_TYPE"] = "config.TestingConfig" + app = create_app() + with app.app_context(): + yield app + + +@pytest.fixture(scope="session") +def test_client(test_app): + with test_app.app_context(): + return test_app.test_client() + + +@pytest.fixture +def bids_db_setup_and_teardown(test_app): + db = test_app.db + collection = db["bids"] + try: + collection.insert_many(bids_data) + # print("----------Bids collection populated----------") + except Exception as e: + raise ConnectionRefusedError( + f"Error while populating the Bids collection: {str(e)}" + ) + + yield + + try: + collection.delete_many({}) + # print("----------Bids collection cleared----------") + except Exception as e: + raise ConnectionRefusedError( + f"Error while clearing the Bids collection: {str(e)}" + ) + + +@pytest.fixture +def questions_db_setup_and_teardown(test_app): + db = test_app.db + collection = db["questions"] + try: + collection.insert_many(questions_data) + # print("----------Questions collection populated----------") + except Exception as e: + raise ConnectionRefusedError( + f"Error while populating the Questions collection: {str(e)}" + ) + + yield + + try: + collection.delete_many({}) + # print("----------Questions collection cleared----------") + except Exception as e: + raise ConnectionRefusedError( + f"Error while clearing the Questions collection: {str(e)}" + ) + + +@pytest.fixture(autouse=True) +def pause_logging(): + logging.disable(logging.CRITICAL) + # print("----------Logging disabled----------") + yield + logging.disable(logging.NOTSET) + # print("----------Logging re-enabled----------") + + +@pytest.fixture(scope="session") +def api_key(): + api_key = os.getenv("API_KEY") + return api_key + + +@pytest.fixture(scope="session") +def basic_jwt(): + payload = {"username": "User McTestface", "admin": False} + key = os.getenv("SECRET_KEY") + token = jwt.encode(payload=payload, key=key) + return token + + +@pytest.fixture(scope="session") +def admin_jwt(): + payload = {"username": "Admin McTestface", "admin": True} + key = os.getenv("SECRET_KEY") + token = jwt.encode(payload=payload, key=key) + return token + + +@pytest.fixture(scope="session") +def max_offset(): + max_offset = os.getenv("MAX_OFFSET") + return int(max_offset) + + +@pytest.fixture(scope="session") +def max_limit(): + max_limit = os.getenv("MAX_LIMIT") + return int(max_limit) + + +@pytest.fixture(scope="session") +def default_offset(): + default_offset = os.getenv("DEFAULT_OFFSET") + return int(default_offset) + + +@pytest.fixture(scope="session") +def default_limit(): + default_limit = os.getenv("DEFAULT_LIMIT") + return int(default_limit) + + +@pytest.fixture(scope="session") +def default_sort_bids(): + default_sort_bids = os.getenv("DEFAULT_SORT_BIDS") + return default_sort_bids + + +@pytest.fixture(scope="session") +def default_sort_questions(): + default_sort_questions = os.getenv("DEFAULT_SORT_QUESTIONS") + return default_sort_questions diff --git a/dbconfig/__init__.py b/dbconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dbconfig/mongo_setup.py b/dbconfig/mongo_setup.py new file mode 100644 index 0000000..0d37a5c --- /dev/null +++ b/dbconfig/mongo_setup.py @@ -0,0 +1,11 @@ +""" +This file contains the configuration for the MongoDB database. +""" + +from pymongo import MongoClient + + +# Create new client, connect to server and return db instance +def get_db(DB_HOST, DB_PORT, DB_NAME): + client = MongoClient(DB_HOST, DB_PORT, serverSelectionTimeoutMS=10000) + return client[DB_NAME] diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.controllers.rst b/docs/api.controllers.rst new file mode 100644 index 0000000..5f85aa7 --- /dev/null +++ b/docs/api.controllers.rst @@ -0,0 +1,21 @@ +api.controllers package +======================= + +Submodules +---------- + +api.controllers.bid\_controller module +-------------------------------------- + +.. automodule:: api.controllers.bid_controller + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: api.controllers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api.models.rst b/docs/api.models.rst new file mode 100644 index 0000000..8cc8d9b --- /dev/null +++ b/docs/api.models.rst @@ -0,0 +1,37 @@ +api.models package +================== + +Submodules +---------- + +api.models.bid\_model module +---------------------------- + +.. automodule:: api.models.bid_model + :members: + :undoc-members: + :show-inheritance: + +api.models.links\_model module +------------------------------ + +.. automodule:: api.models.links_model + :members: + :undoc-members: + :show-inheritance: + +api.models.status\_enum module +------------------------------ + +.. automodule:: api.models.status_enum + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: api.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..477b05e --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,20 @@ +api package +=========== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + api.controllers + api.models + api.schemas + +Module contents +--------------- + +.. automodule:: api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api.schemas.rst b/docs/api.schemas.rst new file mode 100644 index 0000000..1a82e73 --- /dev/null +++ b/docs/api.schemas.rst @@ -0,0 +1,53 @@ +api.schemas package +=================== + +Submodules +---------- + +api.schemas.bid\_id\_schema module +---------------------------------- + +.. automodule:: api.schemas.bid_id_schema + :members: + :undoc-members: + :show-inheritance: + +api.schemas.bid\_schema module +------------------------------ + +.. automodule:: api.schemas.bid_schema + :members: + :undoc-members: + :show-inheritance: + +api.schemas.feedback\_schema module +----------------------------------- + +.. automodule:: api.schemas.feedback_schema + :members: + :undoc-members: + :show-inheritance: + +api.schemas.links\_schema module +-------------------------------- + +.. automodule:: api.schemas.links_schema + :members: + :undoc-members: + :show-inheritance: + +api.schemas.phase\_schema module +-------------------------------- + +.. automodule:: api.schemas.phase_schema + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: api.schemas + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/app.rst b/docs/app.rst new file mode 100644 index 0000000..ceb7f40 --- /dev/null +++ b/docs/app.rst @@ -0,0 +1,7 @@ +app module +========== + +.. automodule:: app + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..486cc4d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,32 @@ +"""Sphinx configuration""" +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) + +PROJECT = "BidsApi Documentation" +COPYRIGHT = "2023, Julio - Pira" +AUTHOR = "Julio - Pira" +RELEASE = "0.4.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.todo", "sphinx.ext.viewcode", "sphinx.ext.autodoc"] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +HTML_THEME = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docs/dbconfig.rst b/docs/dbconfig.rst new file mode 100644 index 0000000..3d8522b --- /dev/null +++ b/docs/dbconfig.rst @@ -0,0 +1,21 @@ +dbconfig package +================ + +Submodules +---------- + +dbconfig.mongo\_setup module +---------------------------- + +.. automodule:: dbconfig.mongo_setup + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: dbconfig + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/helpers.rst b/docs/helpers.rst new file mode 100644 index 0000000..2748ee6 --- /dev/null +++ b/docs/helpers.rst @@ -0,0 +1,21 @@ +helpers package +=============== + +Submodules +---------- + +helpers.helpers module +---------------------- + +.. automodule:: helpers.helpers + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..73b5387 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,20 @@ +.. BidsApi Documentation documentation master file, created by + sphinx-quickstart on Mon Jul 24 19:35:54 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to BidsApi Documentation's documentation! +================================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 0000000..45814d0 --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,12 @@ +tdse-accessForce-bids-api +========================= + +.. toctree:: + :maxdepth: 4 + + app + dbconfig + helpers + models + controllers + api diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/helpers.py b/helpers/helpers.py new file mode 100644 index 0000000..8654ef7 --- /dev/null +++ b/helpers/helpers.py @@ -0,0 +1,225 @@ +""" +This module contains helper functions for the API. +""" +import jwt +import logging +import os +import uuid +from datetime import datetime +from dotenv import load_dotenv +from flask import g, jsonify, request +from functools import wraps +from jwt.exceptions import InvalidTokenError +from werkzeug.exceptions import UnprocessableEntity +from api.schemas.bid_schema import BidSchema +from api.schemas.id_schema import IdSchema +from api.schemas.question_schema import QuestionSchema + +logger = logging.getLogger() + + +def showForbiddenError(): + return jsonify({"Error": "Forbidden"}) + + +def showInternalServerError(): + return jsonify({"Error": "Could not connect to database"}) + + +def showNotFoundError(): + return jsonify({"Error": "Resource not found"}) + + +def showUnauthorizedError(): + return jsonify({"Error": "Unauthorized"}) + + +def showUnprocessableEntityError(error): + return jsonify({"Error": str(error.description)}) + + +def showValidationError(error): + return jsonify({"Error": str(error)}) + + +def is_valid_uuid(string): + try: + uuid.UUID(str(string)) + return True + except ValueError: + return False + + +def is_valid_isoformat(string): + try: + datetime.fromisoformat(string) + return True + except ValueError: + return False + + +def validate_and_create_bid_document(request): + # Process input and create data model + bid_document = BidSchema().load(request) + # Serialize to a JSON object + data = BidSchema().dump(bid_document) + return data + + +def validate_id_path(id): + valid_id = IdSchema().load({"id": id}) + data = valid_id["id"] + return data + + +def validate_bid_update(request, resource): + if "status" in request: + raise UnprocessableEntity("Cannot update status") + resource.update(request) + bid = BidSchema().load(resource, partial=True) + data = BidSchema().dump(bid) + return data + + +def validate_status_update(request, resource): + if request == {}: + raise UnprocessableEntity("Request must not be empty") + resource.update(request) + bid = BidSchema().load(resource, partial=True) + data = BidSchema().dump(bid) + return data + + +def prepend_host_to_links(resource, hostname): + host = f"http://{hostname}" + for key in resource["links"]: + resource["links"][key] = f'{host}{resource["links"][key]}' + return resource + + +def require_api_key(fn): + @wraps(fn) + def validate_api_key(*args, **kwargs): + try: + load_dotenv() + api_key = request.headers.get("X-API-Key") + assert api_key == os.getenv("API_KEY"), "API Key is not valid" + except AssertionError: + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnauthorizedError(), 401 + return fn(*args, **kwargs) + + return validate_api_key + + +def require_jwt(fn): + @wraps(fn) + def validate_jwt(*args, **kwargs): + try: + validate_token(request=request) + except (AssertionError, InvalidTokenError): + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnauthorizedError(), 401 + return fn(*args, **kwargs) + + return validate_jwt + + +def require_admin_access(fn): + @wraps(fn) + def validate_admin(*args, **kwargs): + try: + decoded = validate_token(request=request) + if decoded["admin"] is False: + raise PermissionError("Forbidden") + except PermissionError: + logger.error(f"{g.request_id} failed", exc_info=True) + return showForbiddenError(), 403 + except (AssertionError, InvalidTokenError): + logger.error(f"{g.request_id} failed", exc_info=True) + return showUnauthorizedError(), 401 + return fn(*args, **kwargs) + + return validate_admin + + +def validate_token(request): + prefix = "Bearer " + auth_header = request.headers.get("Authorization") + assert auth_header is not None, "Not authorized" + assert auth_header.startswith(prefix) is True, "Not authorized" + token = auth_header[len(prefix) :] + load_dotenv() + key = os.getenv("SECRET_KEY") + decoded = jwt.decode(token, key, algorithms="HS256") + return decoded + + +def validate_and_create_question_document(request, bid_id): + request["bid_id"] = bid_id + # Process input and create data model + question_document = QuestionSchema().load(request) + # Serialize to a JSON object + data = QuestionSchema().dump(question_document) + return data + + +def validate_question_update(request, resource): + if request == {}: + raise UnprocessableEntity("Request must not be empty") + resource.update(request) + question_document = QuestionSchema().load(resource, partial=True) + data = QuestionSchema().dump(question_document) + return data + + +def validate_pagination(limit, offset): + load_dotenv() + + def validate_param(value, default_value, max_value, param_name): + maximum = int(os.getenv(max_value)) + if value: + try: + valid_value = int(value) + assert maximum > valid_value >= 0 + except (ValueError, AssertionError): + raise ValueError( + f"{param_name} value must be a number between 0 and {maximum}" + ) + else: + valid_value = int(os.getenv(default_value)) + return valid_value + + valid_limit = validate_param(limit, "DEFAULT_LIMIT", "MAX_LIMIT", "Limit") + valid_offset = validate_param(offset, "DEFAULT_OFFSET", "MAX_OFFSET", "Offset") + return valid_limit, valid_offset + + +def validate_sort(sort_value, resource): + load_dotenv() + if resource == "bids": + field = os.getenv("DEFAULT_SORT_BIDS") + valid_fields = [ + "client", + "tender", + "bid_date", + "alias", + "status", + "last_updated", + "was_successful", + ] + elif resource == "questions": + field = os.getenv("DEFAULT_SORT_QUESTIONS") + valid_fields = ["description", "score", "respondents", "status", "last_updated"] + order = 1 + try: + if sort_value: + if sort_value[0] == "-": + field = sort_value[1:] + order = -1 + else: + field = sort_value + assert field in valid_fields + except AssertionError: + raise ValueError("Invalid sort criteria") + return field, order diff --git a/logconfig/custom_formatter.py b/logconfig/custom_formatter.py new file mode 100644 index 0000000..9d0a43a --- /dev/null +++ b/logconfig/custom_formatter.py @@ -0,0 +1,36 @@ +import json, logging, os + +app_name = os.getenv("APP_NAME") +app_version = os.getenv("APP_VERSION") +app_lang = os.getenv("APP_LANG") + + +# Custom JSON Formatter +class CustomJSONFormatter(logging.Formatter): + def format_traceback(self, exc_info): + _, exception_value, tb = exc_info + traceback_info = { + "exc_location": tb.tb_frame.f_code.co_filename, + "line": tb.tb_lineno, + "function": tb.tb_frame.f_code.co_name, + "error": str(exception_value), + } + return traceback_info + + def format(self, record): + formatted_record = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "message": record.getMessage(), + "location": "{}:{}:line {}".format( + record.pathname, record.funcName, record.lineno + ), + "span ID": record.process, + "app": {"name": app_name, "version": app_version, "language": app_lang}, + } + + if record.exc_info: + traceback_info = self.format_traceback(record.exc_info) + formatted_record["exception"] = traceback_info + + return json.dumps(formatted_record, default=str) diff --git a/logconfig/logging_config.json b/logconfig/logging_config.json new file mode 100644 index 0000000..5f0c673 --- /dev/null +++ b/logconfig/logging_config.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "formatters": { + "JSONFormatter": { + "()": "logconfig.custom_formatter.CustomJSONFormatter" + } + }, + "handlers": { + "fileHandler": { + "class": "logging.FileHandler", + "level": "DEBUG", + "formatter": "JSONFormatter", + "filename": "log.log", + "mode": "a" + } + }, + "loggers": { + "root": { + "level": "DEBUG", + "handlers": ["fileHandler"] + }, + "werkzeug": { + "propagate": false, + "qualname": "werkzeug" + } + } +} diff --git a/request_examples/delete_bid.http b/request_examples/delete_bid.http new file mode 100644 index 0000000..dfe4139 --- /dev/null +++ b/request_examples/delete_bid.http @@ -0,0 +1,2 @@ +DELETE http://localhost:8080/api/bids/b4846631-9135-4208-8e37-70eba8f77e15 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlVzZXIiLCJhZG1pbiI6ZmFsc2UsImV4cCI6MTY5MzU3ODU5MH0.KD5xKvfynSjG3VNlH54g2AkceQxtkysxxTM5GaTnnXt diff --git a/request_examples/delete_question.http b/request_examples/delete_question.http new file mode 100644 index 0000000..49d1317 --- /dev/null +++ b/request_examples/delete_question.http @@ -0,0 +1,2 @@ +DELETE http://localhost:8080/api/bids/2529a0be-6e1c-4202-92c7-65c3742dfd4e/questions/b259a93e-7b08-4a80-8daa-8cbb5beebcd9 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlBpcmEiLCJhZG1pbiI6dHJ1ZSwiZXhwIjoxNjkxNTg4NzAyfQ.xTrdupUL7LXt3TRO1EmtMA9M7k-Dt4OfW3RW3qwV3sw \ No newline at end of file diff --git a/request_examples/get_all_bids.http b/request_examples/get_all_bids.http new file mode 100644 index 0000000..51e3ef0 --- /dev/null +++ b/request_examples/get_all_bids.http @@ -0,0 +1,2 @@ +GET http://localhost:8080/api/bids HTTP/1.1 +X-API-Key: THIS_IS_THE_API_KEY \ No newline at end of file diff --git a/request_examples/get_all_question.http b/request_examples/get_all_question.http new file mode 100644 index 0000000..f0a47cd --- /dev/null +++ b/request_examples/get_all_question.http @@ -0,0 +1,2 @@ +GET http://localhost:8080/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlBpcmEiLCJhZG1pbiI6dHJ1ZSwiZXhwIjoxNjkyMTkxNjM1fQ.nyWHYtF4PCz7FLThu3gjHF7D5_JirwuOUWggDP73TBs \ No newline at end of file diff --git a/request_examples/get_jwt_from_auth.http b/request_examples/get_jwt_from_auth.http new file mode 100644 index 0000000..4b74f82 --- /dev/null +++ b/request_examples/get_jwt_from_auth.http @@ -0,0 +1,6 @@ +POST http://localhost:5000/authorise/ HTTP/1.1 +Content-Type: application/json + +{ + "username": "Julio" +} diff --git a/request_examples/post_bid.http b/request_examples/post_bid.http new file mode 100644 index 0000000..d7d2e29 --- /dev/null +++ b/request_examples/post_bid.http @@ -0,0 +1,10 @@ +POST http://localhost:8080/api/bids HTTP/1.1 +Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Ikp1bGlvIiwiYWRtaW4iOnRydWUsImV4cCI6MTY5MTUyNDk0M30.GYWsLyCddSJqxFBCXJc5OMivZsQNQBUTaW6rd0bfq7A + +{ + "tender": "THIS IS A TENDER", + "client": "TEST", + "bid_date": "2023-10-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder" +} diff --git a/request_examples/post_question.http b/request_examples/post_question.http new file mode 100644 index 0000000..512bb05 --- /dev/null +++ b/request_examples/post_question.http @@ -0,0 +1,10 @@ +POST http://localhost:8080/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions HTTP/1.1 +Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlBpcmEiLCJhZG1pbiI6dHJ1ZSwiZXhwIjoxNjk1NjM5NzU5fQ.btdgSJbIC0rxeRI0CE5_mx-VvYbJKqey2ud0_mjoKoQ + +{ + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": {"description": "Good YeI!", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder"} +} diff --git a/request_examples/update_bid.http b/request_examples/update_bid.http new file mode 100644 index 0000000..8efbb88 --- /dev/null +++ b/request_examples/update_bid.http @@ -0,0 +1,7 @@ +PUT http://localhost:8080/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlRlc3RlciBNY1Rlc3RmYWNlIiwiYWRtaW4iOmZhbHNlfQ.Dg7f8LVtALYWvjZH31re5C-Pc6Hp6Ra-U4LAy0ZQQ9M +Content-Type: application/json + +{ + "was_successful": "true" +} diff --git a/request_examples/update_bid_status.http b/request_examples/update_bid_status.http new file mode 100644 index 0000000..b602932 --- /dev/null +++ b/request_examples/update_bid_status.http @@ -0,0 +1,6 @@ +PUT http://localhost:8080/api/bids/7cea822f-fb27-4efd-87b3-3467eeb49d68/status HTTP/1.1 +Content-Type: application/json + +{ + "status": "completed" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..354f071 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +blinker +click +Flask +itsdangerous +Jinja2 +MarkupSafe +Werkzeug +pip == 23.2.0 +flask_swagger_ui +marshmallow +pymongo +python-dotenv +black +flake8 +pylint +coverage +pytest +sphinx +sphinx-rtd-theme +pyjwt \ No newline at end of file diff --git a/scripts/Makefile b/scripts/Makefile new file mode 100644 index 0000000..a6b6d9b --- /dev/null +++ b/scripts/Makefile @@ -0,0 +1,27 @@ +.ONESHELL: + +.PHONY: help authplay bids dbclean questions + +help: + @echo "make help - display this help" + @echo "make authplay - get JWT and interact with auth api" + @echo "make bids - populate bids collection" + @echo "make dbclean - clean up the application database" + @echo "make questions - populate questions collection" + +authplay: + @echo "Getting JWT..." + @find . -name "get_jwt.py" -exec python3 {} \; + +bids: + @echo "Creating bids..." + @find . -name "create_bids.py" -exec python3 {} \; + +dbclean: + @echo "Cleaning up database..." + @find . -name "delete_bids.py" -exec python3 {} \; + @find . -name "delete_questions.py" -exec python3 {} \; + +questions: + @echo "Creating questions..." + @find . -name "create_questions.py" -exec python3 {} \; \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6e9b601 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,36 @@ +# MongoDB Database Cleanup and Sample Data Population Scripts + +## Database Cleanup Script + +### Script Description +- The delete_bids.py script is used to delete all bids and drop all indexes from the MongoDB collection. +- The delete_questions.py script is used to delete all questions and drop all indexes from the MongoDB collection. +- The create_bids.py script is used to populate the MongoDB database with sample bids data from the bids.json file and create indexes. +- The create_questions.py script is used to populate the MongoDB database with sample questions data from the questions.json file, using existing bid Ids from the bids.json file and create indexes. + +### Usage + +To run the database cleanup script, execute the following command: +```bash +make dbclean +``` + +Or to run the cleanup script for only the bids collection, execute: +```bash +python3 delete_bids.py +``` + +And to run the cleanup script for only the questions collection, execute: +```bash +python3 delete_questions.py +``` + +To run the sample bids data population script, execute the following command: +```bash +python3 create_bids.py +``` + +To run the sample questions data population script, execute the following command: +```bash +python3 create_questions.py +``` diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/create_bids.py b/scripts/create_bids.py new file mode 100644 index 0000000..2e00c46 --- /dev/null +++ b/scripts/create_bids.py @@ -0,0 +1,74 @@ +""" + +This script creates sample data for the Bids collection. + +""" + +import os +import json +import sys +from pymongo import MongoClient, operations +from pymongo.errors import ConnectionFailure +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.getenv("DB_HOST") +DB_PORT = 27017 +DB_NAME = os.getenv("DB_NAME") + +if os.environ.get("TESTING"): + DB_NAME = os.getenv("TEST_DB_NAME") + + +def populate_bids(): + """ + Populates the MongoDB database with sample bids data from bids.json file. + """ + try: + client = MongoClient(DB_HOST, DB_PORT, serverSelectionTimeoutMS=10000) + data_base = client[DB_NAME] + collection = data_base["bids"] + + # Get the current script's directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # Construct the file path to bids.json + file_path = os.path.join(current_dir, "test_data", "bids.json") + + # Read bids data from JSON file + with open(file_path, encoding="utf-8") as bids_file: + bids_data = json.load(bids_file) + + # Define the index models + index_models = [ + operations.IndexModel([("client", 1)]), + operations.IndexModel([("tender", 1)]), + operations.IndexModel([("bid_date", 1)]), + operations.IndexModel([("last_updated", -1)]), + ] + + # Create the indexes + collection.create_indexes(index_models) + # Insert bids into the database + for bid in bids_data: + # Check if the bid already exists in the database + existing_bid = collection.find_one({"_id": bid["_id"]}) + if existing_bid: + print(f"Skipping existing bid with _id: {bid['_id']}") + else: + collection.insert_one(bid) + print(f"Inserted bid with _id: {bid['_id']}") + + except ConnectionFailure: + print("Error: Failed to connect to database") + sys.exit(1) + + finally: + # Close the MongoDB connection + client.close() + + +if __name__ == "__main__": + populate_bids() + sys.exit(0) diff --git a/scripts/create_questions.py b/scripts/create_questions.py new file mode 100644 index 0000000..c921b0e --- /dev/null +++ b/scripts/create_questions.py @@ -0,0 +1,98 @@ +""" + +This script creates sample data for the Questions collection. + +""" + +import copy +import json +import os +import sys +import uuid +from pymongo import MongoClient, operations +from pymongo.errors import ConnectionFailure +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.getenv("DB_HOST") +DB_PORT = 27017 +DB_NAME = os.getenv("DB_NAME") + +if os.environ.get("TESTING"): + DB_NAME = os.getenv("TEST_DB_NAME") + + +def populate_questions(): + """ + Populates the MongoDB database with sample questions data from questions.json file. + """ + try: + client = MongoClient(DB_HOST, DB_PORT, serverSelectionTimeoutMS=10000) + data_base = client[DB_NAME] + collection = data_base["questions"] + + # Get the current script's directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # Construct the file path to bids.json + bids_path = os.path.join(current_dir, "test_data", "bids.json") + + # Construct the file path to questions.json + questions_path = os.path.join(current_dir, "test_data", "questions.json") + + # Read bids data from JSON file + with open(bids_path, encoding="utf-8") as bids_file: + bids_data = json.load(bids_file) + + # Read bids data from JSON file + with open(questions_path, encoding="utf-8") as questions_file: + questions_data = json.load(questions_file) + + # Define index models + index_models = [ + operations.IndexModel([("description", 1)]), + operations.IndexModel([("last_updated", -1)]), + ] + + # Create index on default sort field + collection.create_indexes(index_models) + + # Update questions data with existing bid ids from bids.json + updated_questions = [] + + for bid in bids_data: + bid_url = bid["links"]["self"] + bid_status = bid["status"] + questions = copy.deepcopy(questions_data) + + for question in questions: + question_id = uuid.uuid4() + question["links"]["bid"] = bid_url + question["links"]["self"] = f"{bid_url}/questions/{question_id}" + question["status"] = bid_status + question["_id"] = str(question_id) + updated_questions.append(question) + + # Insert questions into the database + for question in updated_questions: + # Check if the question already exists in the database + existing_question = collection.find_one({"_id": question["_id"]}) + if existing_question: + print(f"Skipping existing question with _id: {question['_id']}") + else: + collection.insert_one(question) + print(f"Inserted question with _id: {question['_id']}") + + except ConnectionFailure: + print("Error: Failed to connect to database") + sys.exit(1) + + finally: + # Close the MongoDB connection + client.close() + + +if __name__ == "__main__": + populate_questions() + sys.exit(0) diff --git a/scripts/delete_bids.py b/scripts/delete_bids.py new file mode 100644 index 0000000..1a3c3df --- /dev/null +++ b/scripts/delete_bids.py @@ -0,0 +1,47 @@ +""" +This script deletes all bids from the Bids collection. + +""" + +import os +import sys +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.getenv("DB_HOST") +DB_PORT = 27017 +DB_NAME = os.getenv("DB_NAME") + +if os.environ.get("TESTING"): + DB_NAME = os.getenv("TEST_DB_NAME") + + +def delete_bids(): + """ + Deletes all bids from the MongoDB collection. + """ + try: + client = MongoClient(DB_HOST, DB_PORT, serverSelectionTimeoutMS=10000) + data_base = client[DB_NAME] + collection = data_base["bids"] + + if collection.count_documents({}) == 0: + print("No bids to delete.") + else: + delete_result = collection.delete_many({}) + print(f"Deleted {delete_result.deleted_count} bids from the collection.") + collection.drop_indexes() + except ConnectionFailure: + print("Error: Failed to connect to database") + sys.exit(1) + + finally: + client.close() + + +if __name__ == "__main__": + delete_bids() + sys.exit(0) diff --git a/scripts/delete_questions.py b/scripts/delete_questions.py new file mode 100644 index 0000000..37f09c0 --- /dev/null +++ b/scripts/delete_questions.py @@ -0,0 +1,49 @@ +""" +This script deletes all questions from the Questions collection. + +""" + +import os +import sys +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.getenv("DB_HOST") +DB_PORT = 27017 +DB_NAME = os.getenv("DB_NAME") + +if os.environ.get("TESTING"): + DB_NAME = os.getenv("TEST_DB_NAME") + + +def delete_bids(): + """ + Deletes all bids from the MongoDB collection. + """ + try: + client = MongoClient(DB_HOST, DB_PORT, serverSelectionTimeoutMS=10000) + data_base = client[DB_NAME] + collection = data_base["questions"] + + if collection.count_documents({}) == 0: + print("No questions to delete.") + else: + delete_result = collection.delete_many({}) + print( + f"Deleted {delete_result.deleted_count} questions from the collection." + ) + collection.drop_indexes() + except ConnectionFailure: + print("Error: Failed to connect to database") + sys.exit(1) + + finally: + client.close() + + +if __name__ == "__main__": + delete_bids() + sys.exit(0) diff --git a/scripts/get_jwt.py b/scripts/get_jwt.py new file mode 100644 index 0000000..9493d58 --- /dev/null +++ b/scripts/get_jwt.py @@ -0,0 +1,145 @@ +""" +This script simulates a login to the API and then allows the user to perform +""" + +import requests + + +def simulate_login(username): + try: + payload = {"username": username} + response = requests.post("http://localhost:5000/authorise", json=payload) + response.raise_for_status() # Raise an exception for any HTTP errors + + token_data = response.json() + token = token_data["jwt"] + + return token + except requests.exceptions.RequestException as error: + print("Request Error:", error) + return None + except ValueError as error: + print("Value Error:", error) + return None + + +def post_bid(jwt_token): + try: + tender = input("Enter the tender: ") + client = input("Enter the client: ") + bid_date = input("Enter the bid date (YYYY-MM-DD): ") + bid_folder_url = input("Enter the bid folder URL(https://path-here): ") + payload = { + "tender": tender, + "client": client, + "bid_date": bid_date, + "bid_folder_url": bid_folder_url, + } + headers = {"Authorization": f"Bearer {jwt_token}"} + response = requests.post( + "http://localhost:8080/api/bids", json=payload, headers=headers + ) + response.raise_for_status() # Raise an exception for any HTTP errors + + data = response.json() + print("Post Success (id):", data["_id"]) + except requests.exceptions.RequestException as error: + print("Request Error:", error) + return None + + +def delete_bid(jwt_token): + try: + bid_id = input("Enter the bid ID to delete: ") + headers = {"Authorization": f"Bearer {jwt_token}"} + response = requests.delete( + f"http://localhost:8080/api/bids/{bid_id}", headers=headers + ) + response.raise_for_status() # Raise an exception for any HTTP errors + + print(f"Bid with ID {bid_id} deleted successfully.") + except requests.exceptions.RequestException as error: + print("Request Error:", error) + + +def find_bid_by_id(): + try: + api_key = input("Enter the API key (THIS_IS_THE_API_KEY): ") + headers = {"X-API-Key": api_key} + bid_id = input("Enter the bid ID to find: ") + response = requests.get( + f"http://localhost:8080/api/bids/{bid_id}", headers=headers + ) + response.raise_for_status() # Raise an exception for any HTTP errors + + bid_data = response.json() + print("Bid Data:") + print(bid_data) + except requests.exceptions.RequestException as error: + print("Request Error:", error) + + +def find_all_bids(): + try: + api_key = input("Enter the API key (THIS_IS_THE_API_KEY): ") + headers = {"X-API-Key": api_key} + response = requests.get("http://localhost:8080/api/bids", headers=headers) + response.raise_for_status() # Raise an exception for any HTTP errors + + all_bids_data = response.json() + print("All Bids Data:") + print(all_bids_data) + except requests.exceptions.RequestException as error: + print("Request Error:", error) + + +def access_level(admin): + admin_users = ["Julio", "Pira", "Nathan"] + if admin in admin_users: + print("You are an admin user.") + else: + print("You are not an admin user.") + + +def main(): + menu_choices = [ + "token", + "post", + "delete", + "find id", + "find all", + "access level", + "exit", + ] + username = input("Enter the username for login: ") + jwt_token = simulate_login(username) + while True: + if jwt_token: + print("Menu:") + for i, choice in enumerate(menu_choices): + print(f"{i + 1}. {choice}") + choice = input("Enter your choice: ") + if choice == "1": + print("JWT Token:", jwt_token) + elif choice == "2": + post_bid(jwt_token) + elif choice == "3": + delete_bid(jwt_token) + elif choice == "4": + find_bid_by_id() + elif choice == "5": + find_all_bids() + elif choice == "6": + access_level(username) + elif choice == "7": + print("Goodbye!") + break + else: + print("Invalid choice. Try again.") + else: + print("Login failed. Try again.") + return + + +if __name__ == "__main__": + main() diff --git a/scripts/test_data/bids.json b/scripts/test_data/bids.json new file mode 100644 index 0000000..7a4764c --- /dev/null +++ b/scripts/test_data/bids.json @@ -0,0 +1,332 @@ +[ + { + "_id": "be15c306-c85b-4e67-a9f6-682553c065a1", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 20 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-07-20T17:00:40.510224", + "links": { + "questions": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + "self": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 30 + } + ], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": false + }, + { + "_id": "a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7", + "alias": "ACME", + "bid_date": "2023-07-05", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "ACME Corporation", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 15 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-01T14:30:20.123456", + "links": { + "questions": "/api/bids/a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7/questions", + "self": "/api/bids/a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 28 + } + ], + "tender": "Data Analytics Solution", + "was_successful": false + }, + { + "_id": "e2c91b49-76ac-4d23-a4e3-109d65b8ac6f", + "alias": "XYZ", + "bid_date": "2023-07-10", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "XYZ Innovations", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 18 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-07-20T17:03:19.452381", + "links": { + "questions": "/api/bids/e2c91b49-76ac-4d23-a4e3-109d65b8ac6f/questions", + "self": "/api/bids/e2c91b49-76ac-4d23-a4e3-109d65b8ac6f" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 32 + } + ], + "tender": "Advanced Analytics Platform", + "was_successful": false + }, + { + "_id": "f73e6c4d-9f2d-4e66-8c57-7ae8d20a558e", + "alias": "TechSolutions", + "bid_date": "2023-07-15", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Technology Solutions Ltd.", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 22 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-05T09:15:30.765432", + "links": { + "questions": "/api/bids/f73e6c4d-9f2d-4e66-8c57-7ae8d20a558e/questions", + "self": "/api/bids/f73e6c4d-9f2d-4e66-8c57-7ae8d20a558e" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 34 + } + ], + "tender": "Data Integration and Reporting", + "was_successful": false + }, + { + "_id": "d4e0f4d7-89a1-23b4-c5d6-e7f8a9b0c1d2", + "alias": "GlobeTech", + "bid_date": "2023-07-20", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "GlobeTech Innovations", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 24 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-07T16:45:10.987654", + "links": { + "questions": "/api/bids/d4e0f4d7-89a1-23b4-c5d6-e7f8a9b0c1d2/questions", + "self": "/api/bids/d4e0f4d7-89a1-23b4-c5d6-e7f8a9b0c1d2" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 26 + } + ], + "tender": "Data Solutions Architecture", + "was_successful": false + }, + { + "_id": "c3a2b1d5-e8f7-4c6d-a9b8-7f6e5d4c3b2a", + "alias": "DataCo", + "bid_date": "2023-07-25", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "DataCo Services", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 19 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-09T10:30:00.123456", + "links": { + "questions": "/api/bids/c3a2b1d5-e8f7-4c6d-a9b8-7f6e5d4c3b2a/questions", + "self": "/api/bids/c3a2b1d5-e8f7-4c6d-a9b8-7f6e5d4c3b2a" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 31 + } + ], + "tender": "Data Governance and Compliance", + "was_successful": true + }, + { + "_id": "d9e8f7c6-b5a4-1d3c-7e6f-9b8a0c2d4e5f", + "alias": "TechConnect", + "bid_date": "2023-07-30", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "TechConnect Solutions", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 21 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-11T08:15:30.987654", + "links": { + "questions": "/api/bids/d9e8f7c6-b5a4-1d3c-7e6f-9b8a0c2d4e5f/questions", + "self": "/api/bids/d9e8f7c6-b5a4-1d3c-7e6f-9b8a0c2d4e5f" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 33 + } + ], + "tender": "Data Infrastructure Modernization", + "was_successful": true + }, + { + "_id": "f2e4d6c8-a0b1-42c3-d4e5-f6a7b8c9d0e1", + "alias": "DataGenius", + "bid_date": "2023-08-05", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "DataGenius Labs", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 23 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-13T16:30:00.654321", + "links": { + "questions": "/api/bids/f2e4d6c8-a0b1-42c3-d4e5-f6a7b8c9d0e1/questions", + "self": "/api/bids/f2e4d6c8-a0b1-42c3-d4e5-f6a7b8c9d0e1" + }, + "status": "in_progress", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 35 + } + ], + "tender": "Data Science and Analytics", + "was_successful": true + }, + { + "_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6f", + "alias": "InnoTech", + "bid_date": "2023-07-15", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Innovative Technologies Ltd.", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 10 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-05T15:30:40.987654", + "links": { + "questions": "/api/bids/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6f/questions", + "self": "/api/bids/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6f" + }, + "status": "completed", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 30 + } + ], + "tender": "Innovative Solutions Development", + "was_successful": true + }, + { + "_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4e", + "alias": "TechBiz", + "bid_date": "2023-07-18", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "TechBiz Solutions", + "failed": { + "has_score": true, + "out_of": 36, + "phase": 2, + "score": 19 + }, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-07T12:30:15.987654", + "links": { + "questions": "/api/bids/9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4e/questions", + "self": "/api/bids/9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4e" + }, + "status": "deleted", + "success": [ + { + "has_score": true, + "out_of": 36, + "phase": 1, + "score": 27 + } + ], + "tender": "Technology Consulting Services", + "was_successful": true + } +] diff --git a/scripts/test_data/questions.json b/scripts/test_data/questions.json new file mode 100644 index 0000000..4332f14 --- /dev/null +++ b/scripts/test_data/questions.json @@ -0,0 +1,194 @@ +[ + { + "_id": "6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder" + }, + "last_updated": "2023-08-01T15:08:33.187216", + "links": { + "bid": "", + "self": "/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": ["JohnDoe", "JaneSmith"], + "response": "This is a sample response.", + "score": 3, + "status": "in_progress" + }, + { + "_id": "c9f6d25b-7d36-4cf3-89e7-1df2f1ca4e21", + "description": "Another question", + "feedback": { + "description": "Excellent feedback", + "url": "https://organisation.sharepoint.com/Docs/anotherfolder" + }, + "last_updated": "2023-08-01T10:20:15.123456", + "links": { + "bid": "", + "self": "/questions/c9f6d25b-7d36-4cf3-89e7-1df2f1ca4e21" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/anotherfolder", + "respondents": ["JohnDoe", "JaneSmith", "AlexBrown"], + "response": "This is another response.", + "score": 2, + "status": "in_progress" + }, + { + "_id": "f9d55b4f-8b34-434b-b768-c6b9b1e9e2c6", + "description": "Third question", + "feedback": { + "description": "Average feedback", + "url": "https://organisation.sharepoint.com/Docs/thirdfolder" + }, + "last_updated": "2023-08-01T12:45:55.987654", + "links": { + "bid": "", + "self": "/questions/f9d55b4f-8b34-434b-b768-c6b9b1e9e2c6" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/thirdfolder", + "respondents": ["AlexBrown"], + "response": "This is the third response.", + "score": 2, + "status": "in_progress" + }, + { + "_id": "1b4c0c2e-2e6f-41d1-836c-43eb32c2d2b1", + "description": "Fourth question", + "feedback": { + "description": "Negative feedback", + "url": "https://organisation.sharepoint.com/Docs/fourthfolder" + }, + "last_updated": "2023-08-01T14:30:40.654321", + "links": { + "bid": "", + "self": "/questions/1b4c0c2e-2e6f-41d1-836c-43eb32c2d2b1" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/fourthfolder", + "respondents": ["JohnDoe"], + "response": "This is a negative response.", + "score": 1, + "status": "completed" + }, + { + "_id": "f1b4e0a9-9e4c-4726-9a53-1a64ef684828", + "description": "Fifth question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/fifthfolder" + }, + "last_updated": "2023-08-01T16:15:22.345678", + "links": { + "bid": "", + "self": "/questions/f1b4e0a9-9e4c-4726-9a53-1a64ef684828" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/fifthfolder", + "respondents": ["JaneSmith", "AlexBrown"], + "response": "This is the fifth response.", + "score": 3, + "status": "completed" + }, + { + "_id": "67c6b84d-3e8f-4c0e-b4c3-2e46e42e2d2d", + "description": "Sixth question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/sixthfolder" + }, + "last_updated": "2023-08-02T10:15:10.987654", + "links": { + "bid": "", + "self": "/questions/67c6b84d-3e8f-4c0e-b4c3-2e46e42e2d2d" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/sixthfolder", + "respondents": ["JohnDoe"], + "response": "This is the sixth response.", + "score": 2, + "status": "completed" + }, + { + "_id": "15f13a6e-81d1-49c1-94d2-82bc79a1e968", + "description": "Seventh question", + "feedback": { + "description": "Average feedback", + "url": "https://organisation.sharepoint.com/Docs/seventhfolder" + }, + "last_updated": "2023-08-02T12:30:25.567890", + "links": { + "bid": "", + "self": "/questions/15f13a6e-81d1-49c1-94d2-82bc79a1e968" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/seventhfolder", + "respondents": ["JaneSmith", "AlexBrown"], + "response": "This is the seventh response.", + "score": 2, + "status": "deleted" + }, + { + "_id": "3a45123c-24a1-4d7f-b792-45d9dd29fc29", + "description": "Eighth question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/eighthfolder" + }, + "last_updated": "2023-08-03T09:45:18.111222", + "links": { + "bid": "", + "self": "/questions/3a45123c-24a1-4d7f-b792-45d9dd29fc29" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/eighthfolder", + "respondents": ["JohnDoe", "JaneSmith", "AlexBrown"], + "response": "This is the eighth response.", + "score": 3, + "status": "deleted" + }, + { + "_id": "b259a93e-7b08-4a80-8daa-8cbb5beebcd9", + "description": "Ninth question", + "feedback": { + "description": "Negative feedback", + "url": "https://organisation.sharepoint.com/Docs/ninthfolder" + }, + "last_updated": "2023-08-03T11:20:30.222333", + "links": { + "bid": "", + "self": "/questions/b259a93e-7b08-4a80-8daa-8cbb5beebcd9" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/ninthfolder", + "respondents": ["JohnDoe"], + "response": "This is a negative response.", + "score": 1, + "status": "deleted" + }, + { + "_id": "b3f1d6f7-5e8c-4b9a-9d18-6a0c1e2d3f4g", + "description": "Tenth question", + "feedback": { + "description": "Average feedback", + "url": "https://organisation.sharepoint.com/Docs/tenthfolder" + }, + "last_updated": "2023-08-04T14:30:40.123456", + "links": { + "bid": "", + "self": "/questions/b3f1d6f7-5e8c-4b9a-9d18-6a0c1e2d3f4g" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/tenthfolder", + "respondents": ["JaneSmith"], + "response": "This is the tenth response.", + "score": 2, + "status": "in_progress" + } + ] + + \ No newline at end of file diff --git a/static/swagger_config.yml b/static/swagger_config.yml new file mode 100644 index 0000000..8d351fc --- /dev/null +++ b/static/swagger_config.yml @@ -0,0 +1,957 @@ +# --- +openapi: 3.0.3 +# -------------------------------------------- +# Info +info: + title: Bids API + description: | + This is a API for the Bids API. + You can find out more about + this API at [GitHub](https://github.com/methods/tdse-accessForce-bids-api). + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + Some useful links: + - [API repository](https://github.com/methods/tdse-accessForce-bids-api) + version: 1.0.0 + termsOfService: 'https://github.com/methods/tdse-accessForce-bids-api' + contact: + email: accessForce@example.com + license: + name: MIT License + url: 'https://github.com/methods/tdse-accessForce-bids-api/blob/develop/LICENSE.md' +# -------------------------------------------- +# Server +servers: + - url: 'http://localhost:8080/api/' + description: Local server +# -------------------------------------------- +# Tags +tags: + - name: bids + description: Everything about BIDS + externalDocs: + description: Find out more + url: example.com + - name: questions + description: Everything about QUESTIONS + externalDocs: + description: Find out more + url: example.com +# -------------------------------------------- +# Paths +paths: + /bids: +# -------------------------------------------- + get: + tags: + - bids + summary: Returns a list of bids + description: A JSON with item count and array of all bids + parameters: + - name: sort + in: query + description: field for sorting list of results in ascending order ("-" before the field will denote descending order) + required: false + schema: + type: string + - name: limit + in: query + description: number of results to return per page + required: false + schema: + type: string + format: integer + - name: offset + in: query + description: number of items skipped from start of dataset on current page + required: false + schema: + type: string + format: integer + security: + - ApiKeyAuth: [] + responses: + '200': # status code + description: Successful operation + content: + application/json: + schema: + type: object + properties: + total_count: + type: integer + example: 1 + count: + type: integer + example: 1 + offset: + type: integer + example: 0 + limit: + type: integer + example: 20 + items: + type: array + items: + $ref: '#/components/schemas/Bid' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + post: + tags: + - bids + summary: Create a new bid + description: Create a new bid + operationId: post_bid + requestBody: + $ref: '#/components/requestBodies/PostBid' + required: true + security: + - BearerAuth: [] + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /bids/{bid_id}: +# -------------------------------------------- + get: + tags: + - bids + summary: Returns a single bid + description: Returns a single bid + operationId: get_bid + parameters: + - name: bid_id + in: path + description: ID of bid to return + required: true + schema: + type: string + format: uuid + security: + - ApiKeyAuth: [] + responses: + '200': + description: A single bid + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + put: + tags: + - bids + summary: Update an existing bid + description: Update an existing bid by id + operationId: update_bid + parameters: + - name: bid_id + in: path + description: ID of bid to update + required: true + schema: + type: string + format: uuid + requestBody: + $ref: '#/components/requestBodies/UpdateBid' + required: true + security: + - BearerAuth: [] + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + delete: + tags: + - bids + summary: Soft delete a bid + description: Soft delete a bid + operationId: delete_bid + parameters: + - name: bid_id + in: path + description: ID of bid to delete + required: true + schema: + type: string + format: uuid + security: + - BearerAuth: [] + responses: + # return 204 (No Content) + '204': + description: Bid deleted + content: + noContent: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /bids/{bid_id}/status: +# -------------------------------------------- + put: + tags: + - bids + summary: Update status of an existing bid + description: Update status of an existing bid + operationId: update_bid_status + parameters: + - name: bid_id + in: path + description: ID of bid to update + required: true + schema: + type: string + format: uuid + requestBody: + $ref: '#/components/requestBodies/UpdateBid' + required: true + security: + - BearerAuth: [] + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Bid' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /bids/{bid_id}/questions: + # -------------------------------------------- + get: + tags: + - questions + summary: Returns a list of questions for a bid + description: A JSON with item count and array of all questions for a bid + operationId: get_questions + parameters: + - name: bid_id + in: path + description: ID of bid to return questions for + required: true + schema: + type: string + format: uuid + - name: sort + in: query + description: field for sorting list of results in ascending order ("-" before the field will denote descending order) + required: false + schema: + type: string + - name: limit + in: query + description: number of results to return per page + required: false + schema: + type: string + format: integer + - name: offset + in: query + description: number of items skipped from start of dataset on current page + required: false + schema: + type: string + format: integer + security: + - ApiKeyAuth: [] + responses: + '200': + description: Successful operation + content: + application/json: + schema: + type: object + properties: + total_count: + type: integer + example: 1 + count: + type: integer + example: 1 + offset: + type: integer + example: 0 + limit: + type: integer + example: 20 + items: + type: array + items: + $ref: '#/components/schemas/Question' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + post: + tags: + - questions + summary: Create a new question for a bid + description: Create a new question for a bid + operationId: post_question + parameters: + - name: bid_id + in: path + description: ID of bid to add question to + required: true + schema: + type: string + format: uuid + requestBody: + $ref: '#/components/requestBodies/PostQuestion' + required: true + security: + - BearerAuth: [] + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + total_count: + type: integer + example: 1 + items: + type: array + items: + $ref: '#/components/schemas/Question' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /bids/{bid_id}/questions/{question_id}: + get: + tags: + - questions + summary: Returns a single question for a bid + description: Returns a single question for a bid + operationId: get_question + parameters: + - name: bid_id + in: path + description: ID of bid to return question for + required: true + schema: + type: string + format: uuid + - name: question_id + in: path + description: ID of question to return + required: true + schema: + type: string + format: uuid + security: + - ApiKeyAuth: [] + responses: + '200': + description: A single question + content: + application/json: + schema: + $ref: '#/components/schemas/Question' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + put: + tags: + - questions + summary: Update an existing question for a bid + description: Update an existing question for a bid + operationId: update_question + parameters: + - name: bid_id + in: path + description: ID of bid to update question for + required: true + schema: + type: string + format: uuid + - name: question_id + in: path + description: ID of question to update + required: true + schema: + type: string + format: uuid + requestBody: + $ref: '#/components/requestBodies/PostQuestion' + required: true + security: + - BearerAuth: [] + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Question' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalServerError' + # -------------------------------------------- + delete: + tags: + - questions + summary: Hard delete a question for a bid + description: Hard delete a question for a bid + operationId: delete_question + parameters: + - name: bid_id + in: path + description: ID of bid to delete question for + required: true + schema: + type: string + format: uuid + - name: question_id + in: path + description: ID of question to delete + required: true + schema: + type: string + format: uuid + security: + - BearerAuth: [] + responses: + # return 204 (No Content) + '204': + description: Question deleted + content: + noContent: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- +# Components +components: +# Schemas + schemas: + Bid: + description: Bid document + type: object + required: + - _id + - tender + - client + - bid_date + properties: + tender: + type: string + example: 'Business Intelligence and Data Warehousing' + bid_folder_url: + type: string + example: 'https://organisation.sharepoint.com/Docs/dummyfolder' + last_updated: + type: string + example: "2023-06-27T14:05:17.623827" + failed: + type: object + $ref: '#/components/schemas/Phase' + example: '#/components/schemas/Phase' + feedback: + type: object + $ref: '#/components/schemas/Feedback' + success: + type: array + items: + type: object + $ref: '#/components/schemas/Phase' + alias: + type: string + example: 'ONS' + client: + type: string + example: 'Office for National Statistics' + links: + type: object + required: + - questions + - self + properties: + questions: + $ref: '#/components/schemas/QuestionsLink' + self: + $ref: '#/components/schemas/SelfLink' + _id: + type: string + format: uuid + example: "471fea1f-705c-4851-9a5b-df7bc2651428" + bid_date: + type: string + format: ISO-8601 + example: '2023-06-21' + status: + type: string + description: Bid Status + example: in_progress + enum: + - in_progress + - deleted + - completed + was_successful: + type: boolean + example: true +# -------------------------------------------- + Phase: + description: Phase information + type: object + required: + - phase + - has_score + properties: + phase: + description: Phase of bid + type: integer + enum: + - 1 + - 2 + example: 2 + score: + description: Score achieved at phase + type: integer + example: 22 + has_score: + description: Score information available or not + type: boolean + example: true + out_of: + description: Maximum score + type: integer + example: 36 +# -------------------------------------------- + Feedback: + description: Feedback from client (if provided) + type: object + required: + - url + - description + properties: + url: + description: Link to feedback + type: string + example: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: + description: Summary of feedback + type: string + example: 'Feedback from client in detail' +# -------------------------------------------- + BidLink: + description: A link to a bid + type: string + example: 'https://{hostname}/api/bids/471fea1f-705c-4851-9a5b-df7bc2651428' +# -------------------------------------------- + Question: + description: Question for a bid + type: object + required: + - _id + - description + - question_url + - feedback + - last_updated + - links + - status + properties: + _id: + type: string + format: uuid + example: "471fea1f-705c-4851-9a5b-df7bc2651428" + description: + description: Question + type: string + example: 'THIS IS A QUESTION' + response: + description: Answer + type: string + example: 'THIS IS AN ANSWER' + question_url: + description: Link to question + type: string + example: 'https://organisation.sharepoint.com/Docs/dummyfolder/question' + score: + description: Score achieved at phase + type: string + example: "10" + out_of: + description: Maximum score + type: string + example: "20" + feedback: + type: object + $ref: '#/components/schemas/Feedback' + status: + type: string + description: Bid Status + example: in_progress + enum: + - in_progress + - deleted + - completed + respondents: + type: array + items: + type: string + example: 'ONS' + last_updated: + type: string + example: "2023-06-27T14:05:17.623827" + links: + type: object + required: + - self + properties: + self: + $ref: '#/components/schemas/SelfQuestionLink' + bid: + $ref: '#/components/schemas/SelfLink' +# -------------------------------------------- + QuestionsLink: + description: A link to a collection of questions for a bid + type: string + example: 'http://{hostname}/api//bids/96d69775-29af-46b1-aaf4-bfbdb1543412/questions' +# -------------------------------------------- + SelfLink: + description: A link to the current resource + type: string + example: 'http://{hostname}/api/bids/471fea1f-705c-4851-9a5b-df7bc2651428' +# -------------------------------------------- + SelfQuestionLink: + description: A link to the current resource + type: string + example: 'http://{hostname}/api/bids/471fea1f-705c-4851-9a5b-df7bc2651428/questions/471fea1f-705c-4851-9a5b-df7bc2651428' +# -------------------------------------------- + BidRequestBody: + type: object + required: + - tender + - client + - bid_date + properties: + tender: + description: Name of tender + type: string + bid_folder_url: + description: Link to bid info + type: string + feedback: + description: Feedback info + type: object + $ref: '#/components/schemas/Feedback' + client: + description: Name of client tendering + type: string + alias: + description: Client alias + type: string + bid_date: + description: Date of bid + type: string + failed_phase: + description: Failed phase info + type: object + $ref: '#/components/schemas/Phase' + success_list: + description: List of successful phases + type: array + items: + $ref: '#/components/schemas/Phase' +# -------------------------------------------- + QuestionRequestBody: + type: object + required: + - description + - feedback + - question_url + properties: + description: + description: Question + type: string + response: + description: Answer + type: string + question_url: + description: Link to question + type: string + score: + description: Score achieved at phase + type: integer + out_of: + description: Maximum score + type: integer + feedback: + type: object + $ref: '#/components/schemas/Feedback' + respondents: + type: string + example: 'ONS' +# -------------------------------------------- +# Security schemes + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +# -------------------------------------------- +# Request bodies + requestBodies: + PostBid: + description: Bid object to be added to collection + content: + application/json: + schema: + $ref: '#/components/schemas/BidRequestBody' + examples: + 200 OK: + summary: 200 OK + value: + tender: 'Business Intelligence and Data Warehousing' + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '2023-06-21' + success: [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ] + failed: { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } + was_successful: false + 400 Bad Request: + summary: 400 Bad Request + value: + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '2023-06-21' + UpdateBid: + description: Bid object to replace bid by Id + content: + application/json: + schema: + $ref: '#/components/schemas/BidRequestBody' + examples: + 200 OK: + summary: 200 OK + value: + tender: 'THIS HAS BEEN UPDATED' + bid_folder_url: 'https://organisation.sharepoint.com/Docs/dummyfolder' + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + client: 'Office for National Statistics' + alias: 'ONS' + bid_date: '2023-06-21' + success: [ + { + "phase": 1, + "has_score": true, + "score": 28, + "out_of": 36 + } + ] + failed: { + "phase": 2, + "has_score": true, + "score": 20, + "out_of": 36 + } + was_successful: false + UpdateStatus: + description: Request body to update bid status + content: + application/json: + schema: + properties: + status: + description: Status of bid + type: string + format: enum + examples: + 200 OK: + summary: 200 OK + value: + status: 'completed' + PostQuestion: + description: Question object to be added to collection + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionRequestBody' + examples: + 200 OK: + summary: 200 OK + value: + description: 'THIS IS A QUESTION' + response: 'THIS IS AN ANSWER' + question_url: 'https://organisation.sharepoint.com/Docs/dummyfolder/question' + score: 10 + out_of: 20 + feedback: + url: 'https://organisation.sharepoint.com/Docs/dummyfolder/feedback' + description: 'Feedback from client in detail' + respondents: 'ONS' + 400 Bad Request: + summary: 400 Bad Request + value: + description: 'THIS IS A QUESTION' + response: 'THIS IS AN ANSWER' + score: 10 + out_of: 20 + respondents: 'ONS' +# -------------------------------------------- +# Error responses + responses: + BadRequest: + description: Bad Request Error + content: + application/json: + schema: + type: object + example: { + "Error": "{'{field}': ['{message}']}" + } + Forbidden: + description: Forbidden + content: + application/json: + schema: + type: object + example: { + "Error": "Forbidden" + } + NotFound: + description: Not Found Error + content: + application/json: + schema: + type: object + example: { + "Error": "Resource not found" + } + InternalServerError: + description: Internal Server Error + content: + application/json: + schema: + type: object + example: { + "Error": "Could not connect to database" + } + Unauthorized: + description: Unauthorized - invalid or missing API key / token + content: + application/json: + schema: + type: object + example: { + "Error": "Unauthorised" + } + UnprocessableEntity: + description: Unprocessable Entity + content: + application/json: + schema: + type: object + example: { + "Error": "Request must not be empty" + } +# -------------------------------------------- \ No newline at end of file diff --git a/tests/integration/bids.json b/tests/integration/bids.json new file mode 100644 index 0000000..d09e6b2 --- /dev/null +++ b/tests/integration/bids.json @@ -0,0 +1,44 @@ +[ + { + "_id": "be15c306-c85b-4e67-a9f6-682553c065a1", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": {"has_score": true, "out_of": 36, "phase": 2, "score": 20}, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-07-20T17:00:40.510224", + "links": { + "questions": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + "self": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1" + }, + "status": "in_progress", + "success": [{"has_score": true, "out_of": 36, "phase": 1, "score": 30}], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": false + }, + { + "_id": "a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7", + "alias": "ACME", + "bid_date": "2023-07-05", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "ACME Corporation", + "failed": {"has_score": true, "out_of": 36, "phase": 2, "score": 15}, + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback" + }, + "last_updated": "2023-08-01T14:30:20.123456", + "links": { + "questions": "/api/bids/a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7/questions", + "self": "/api/bids/a5e8f31b-d848-4e87-b5c9-8db5a9d72bc7" + }, + "status": "in_progress", + "success": [{"has_score": true, "out_of": 36, "phase": 1, "score": 28}], + "tender": "Data Analytics Solution", + "was_successful": false + } +] \ No newline at end of file diff --git a/tests/integration/questions.json b/tests/integration/questions.json new file mode 100644 index 0000000..f8f4fd0 --- /dev/null +++ b/tests/integration/questions.json @@ -0,0 +1,40 @@ +[ + { + "_id": "6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder" + }, + "last_updated": "2023-08-01T15:08:33.187216", + "links": { + "bid": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1", + "self": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": ["JohnDoe", "JaneSmith"], + "response": "This is a sample response.", + "score": 3, + "status": "in_progress" + }, + { + "_id": "c9f6d25b-7d36-4cf3-89e7-1df2f1ca4e21", + "description": "Another question", + "feedback": { + "description": "Excellent feedback", + "url": "https://organisation.sharepoint.com/Docs/anotherfolder" + }, + "last_updated": "2023-08-01T10:20:15.123456", + "links": { + "bid": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1", + "self": "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions/c9f6d25b-7d36-4cf3-89e7-1df2f1ca4e21" + }, + "out_of": 3, + "question_url": "https://organisation.sharepoint.com/Docs/anotherfolder", + "respondents": ["JohnDoe", "JaneSmith", "AlexBrown"], + "response": "This is another response.", + "score": 2, + "status": "in_progress" + } +] \ No newline at end of file diff --git a/tests/integration/test_delete_bid.py b/tests/integration/test_delete_bid.py new file mode 100644 index 0000000..5048dc0 --- /dev/null +++ b/tests/integration/test_delete_bid.py @@ -0,0 +1,14 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_delete_bid(test_app, test_client, admin_jwt): + headers = {"host": "localhost:8080", "Authorization": f"Bearer {admin_jwt}"} + id = "be15c306-c85b-4e67-a9f6-682553c065a1" + + response = test_client.delete(f"/api/bids/{id}", headers=headers) + + assert response.status_code == 204 + assert test_app.db["bids"].count_documents({"status": {"$ne": "deleted"}}) == 1 diff --git a/tests/integration/test_delete_question.py b/tests/integration/test_delete_question.py new file mode 100644 index 0000000..12f8529 --- /dev/null +++ b/tests/integration/test_delete_question.py @@ -0,0 +1,23 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures( + "bids_db_setup_and_teardown", "questions_db_setup_and_teardown" +) + + +def test_delete_question(test_app, test_client, admin_jwt): + headers = { + "host": "localhost:8080", + "Content-Type": "application/json", + "Authorization": f"Bearer {admin_jwt}", + } + id = "6e7d3f8a-fab3-4ebf-8348-96d0808d325e" + + response = test_client.delete( + f"/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions/{id}", + headers=headers, + ) + + assert response.status_code == 204 + assert test_app.db["questions"].count_documents({"status": {"$ne": "deleted"}}) == 1 diff --git a/tests/integration/test_get_bid_by_id.py b/tests/integration/test_get_bid_by_id.py new file mode 100644 index 0000000..072f72b --- /dev/null +++ b/tests/integration/test_get_bid_by_id.py @@ -0,0 +1,17 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_get_bid_by_id(test_client, api_key): + headers = { + "host": "localhost:8080", + "X-API-Key": api_key, + } + id = "be15c306-c85b-4e67-a9f6-682553c065a1" + + response = test_client.get(f"/api/bids/{id}", headers=headers) + + assert response.status_code == 200 + assert response.get_json()["_id"] == id diff --git a/tests/integration/test_get_bids.py b/tests/integration/test_get_bids.py new file mode 100644 index 0000000..81d2f6e --- /dev/null +++ b/tests/integration/test_get_bids.py @@ -0,0 +1,16 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_get_bids(test_client, api_key): + headers = { + "host": "localhost:8080", + "X-API-Key": api_key, + } + + response = test_client.get("/api/bids", headers=headers) + + assert response.status_code == 200 + assert len(response.get_json()["items"]) == 2 diff --git a/tests/integration/test_get_question_by_id.py b/tests/integration/test_get_question_by_id.py new file mode 100644 index 0000000..f6af466 --- /dev/null +++ b/tests/integration/test_get_question_by_id.py @@ -0,0 +1,22 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures( + "bids_db_setup_and_teardown", "questions_db_setup_and_teardown" +) + + +def test_get_question_by_id(test_client, api_key): + headers = { + "host": "localhost:8080", + "X-API-Key": api_key, + } + id = "6e7d3f8a-fab3-4ebf-8348-96d0808d325e" + + response = test_client.get( + f"/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions/{id}", + headers=headers, + ) + + assert response.status_code == 200 + assert response.get_json()["_id"] == id diff --git a/tests/integration/test_get_questions.py b/tests/integration/test_get_questions.py new file mode 100644 index 0000000..e309d0e --- /dev/null +++ b/tests/integration/test_get_questions.py @@ -0,0 +1,20 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures( + "bids_db_setup_and_teardown", "questions_db_setup_and_teardown" +) + + +def test_get_questions(test_client, api_key): + headers = { + "host": "localhost:8080", + "X-API-Key": api_key, + } + + response = test_client.get( + "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", headers=headers + ) + + assert response.status_code == 200 + assert len(response.get_json()["items"]) == 2 diff --git a/tests/integration/test_post_bid.py b/tests/integration/test_post_bid.py new file mode 100644 index 0000000..2318045 --- /dev/null +++ b/tests/integration/test_post_bid.py @@ -0,0 +1,26 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_post_bid(test_app, test_client, basic_jwt): + headers = { + "host": "localhost:8080", + "Content-Type": "application/json", + "Authorization": f"Bearer {basic_jwt}", + } + + data = { + "bid_date": "2023-07-10", + "client": "XYZ Innovations", + "tender": "Advanced Analytics Platform", + } + + response = test_client.post(f"/api/bids", json=data, headers=headers) + + assert response.status_code == 201 + assert test_app.db["bids"].count_documents({}) == 3 + assert response.get_json()["_id"] is not None + assert response.get_json()["last_updated"] is not None + assert response.get_json()["links"] is not None diff --git a/tests/integration/test_post_question.py b/tests/integration/test_post_question.py new file mode 100644 index 0000000..d4f2c9b --- /dev/null +++ b/tests/integration/test_post_question.py @@ -0,0 +1,35 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures( + "bids_db_setup_and_teardown", "questions_db_setup_and_teardown" +) + + +def test_post_question(test_app, test_client, basic_jwt): + headers = { + "host": "localhost:8080", + "Content-Type": "application/json", + "Authorization": f"Bearer {basic_jwt}", + } + + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "This is a description", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + + response = test_client.post( + f"/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers=headers, + ) + + assert response.status_code == 201 + assert test_app.db["questions"].count_documents({}) == 3 + assert response.get_json()["_id"] is not None + assert response.get_json()["last_updated"] is not None + assert response.get_json()["links"] is not None diff --git a/tests/integration/test_update_bid.py b/tests/integration/test_update_bid.py new file mode 100644 index 0000000..630b79a --- /dev/null +++ b/tests/integration/test_update_bid.py @@ -0,0 +1,20 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_update_bid(test_app, test_client, basic_jwt): + headers = {"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"} + + id = "be15c306-c85b-4e67-a9f6-682553c065a1" + + data = {"tender": "THIS IS UPDATED"} + + response = test_client.put(f"/api/bids/{id}", json=data, headers=headers) + + bid = test_app.db["bids"].find_one({"_id": id}) + + assert response.status_code == 200 + assert response.get_json()["tender"] == "THIS IS UPDATED" + assert bid["tender"] == "THIS IS UPDATED" diff --git a/tests/integration/test_update_bid_status.py b/tests/integration/test_update_bid_status.py new file mode 100644 index 0000000..9bb37f3 --- /dev/null +++ b/tests/integration/test_update_bid_status.py @@ -0,0 +1,20 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures("bids_db_setup_and_teardown") + + +def test_update_bid_status(test_app, test_client, admin_jwt): + headers = {"host": "localhost:8080", "Authorization": f"Bearer {admin_jwt}"} + + id = "be15c306-c85b-4e67-a9f6-682553c065a1" + + data = {"status": "completed"} + + response = test_client.put(f"/api/bids/{id}/status", json=data, headers=headers) + + bid = test_app.db["bids"].find_one({"_id": id}) + + assert response.status_code == 200 + assert response.get_json()["status"] == "completed" + assert bid["status"] == "completed" diff --git a/tests/integration/test_update_question.py b/tests/integration/test_update_question.py new file mode 100644 index 0000000..b0cec8c --- /dev/null +++ b/tests/integration/test_update_question.py @@ -0,0 +1,30 @@ +import pytest + + +pytestmark = pytest.mark.usefixtures( + "bids_db_setup_and_teardown", "questions_db_setup_and_teardown" +) + + +def test_update_question(test_app, test_client, basic_jwt): + headers = { + "host": "localhost:8080", + "Content-Type": "application/json", + "Authorization": f"Bearer {basic_jwt}", + } + + id = "6e7d3f8a-fab3-4ebf-8348-96d0808d325e" + + data = {"description": "THIS IS UPDATED"} + + response = test_client.put( + f"/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions/{id}", + json=data, + headers=headers, + ) + + question = test_app.db["questions"].find_one({"_id": id}) + + assert response.status_code == 200 + assert response.get_json()["description"] == "THIS IS UPDATED" + assert question["description"] == "THIS IS UPDATED" diff --git a/tests/unit/test_bid_schema.py b/tests/unit/test_bid_schema.py new file mode 100644 index 0000000..59eeda0 --- /dev/null +++ b/tests/unit/test_bid_schema.py @@ -0,0 +1,176 @@ +""" +This module contains tests for the bid schema. +""" +import pytest +from marshmallow import ValidationError +from api.schemas.bid_schema import BidSchema +from helpers.helpers import is_valid_uuid, is_valid_isoformat + + +# Case 1: New instance of bid model class generates expected fields +def test_bid_model(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "success": [{"phase": 1, "has_score": True, "out_of": 36, "score": 30}], + "failed": {"phase": 2, "has_score": True, "score": 20, "out_of": 36}, + } + bid_document = BidSchema().load(data) + to_post = BidSchema().dump(bid_document) + + bid_id = to_post["_id"] + # Test that UUID is generated and is valid UUID + assert to_post["_id"] is not None + assert is_valid_uuid(bid_id) is True + # Test UUID validator + assert is_valid_uuid("99999") is False + + # Test that links object is generated and URLs are correct + assert to_post["links"] is not None + assert "self" in to_post["links"] + assert to_post["links"]["self"] == f"/api/bids/{bid_id}" + assert "questions" in to_post["links"] + assert to_post["links"]["questions"] == f"/api/bids/{bid_id}/questions" + + # Test that status is set to in_progress + assert to_post["status"] == "in_progress" + + # Test that last_updated field has been added and is valid + assert to_post["last_updated"] is not None + assert is_valid_isoformat(to_post["last_updated"]) is True + # Test ISOformat validator + assert is_valid_isoformat("07-06-2023") is False + + +# Case 2: Field validation - tender +def test_validate_tender(): + data = { + "tender": 42, + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + } + with pytest.raises(ValidationError): + BidSchema().load(data) + + +# Case 3: Field validation - client +def test_validate_client(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": 42, + "bid_date": "2023-06-21", + } + with pytest.raises(ValidationError): + BidSchema().load(data) + + +# Case 4: Field validation - bid_date +def test_validate_bid_date(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "20-12-2023", + } + with pytest.raises(ValidationError): + BidSchema().load(data) + + +# Case 5: Field validation - bid_folder_url +def test_validate_bid_folder_url(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "bid_folder_url": "Not a valid URL", + } + + with pytest.raises(ValidationError): + BidSchema().load(data) + + +# Case 6: Field validation - feedback +def test_validate_feedback(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": {"description": 42, "url": "Invalid URL"}, + } + + with pytest.raises(ValidationError): + BidSchema().load(data) + + +# Case 7: Failed phase cannot be more than 2 +def test_failed_phase_greater_than_2(): + data = { + "failed": {"phase": 3, "has_score": True, "score": 20, "out_of": 36}, + } + + with pytest.raises(ValidationError, match="Must be one of: 1, 2."): + BidSchema().load(data, partial=True) + + +# Case 8: Success phase cannot be more than 2 +def test_success_phase_greater_than_2(): + data = {"success": [{"phase": 4, "has_score": True, "out_of": 36, "score": 30}]} + + with pytest.raises(ValidationError, match="Must be one of: 1, 2."): + BidSchema().load(data, partial=True) + + +# Case 9: Success cannot have the same phase in the list +def test_phase_already_exists_in_success(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "success": [ + {"phase": 1, "has_score": True, "out_of": 36, "score": 30}, + {"phase": 1, "has_score": True, "out_of": 50, "score": 60}, + ], + } + + with pytest.raises( + ValidationError, + match="Phase values must be unique", + ): + BidSchema().load(data, partial=True) + + +# Case 10: Success cannot contain same phase value as failed +def test_same_phase(): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "failed": {"phase": 1, "has_score": True, "score": 20, "out_of": 36}, + "success": [{"phase": 1, "has_score": True, "out_of": 36, "score": 30}], + } + + with pytest.raises( + ValidationError, + match="Phase values must be unique", + ): + BidSchema().load(data, partial=True) diff --git a/tests/unit/test_delete_bid.py b/tests/unit/test_delete_bid.py new file mode 100644 index 0000000..c6958c2 --- /dev/null +++ b/tests/unit/test_delete_bid.py @@ -0,0 +1,85 @@ +""" +This file contains the tests for the DELETE /api/bids/ endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful delete a bid by changing status to deleted +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_success(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one_and_update.return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "status": "deleted", + } + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 204 + + +# Case 2: Failed to call database +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_connection_error(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one_and_update.side_effect = Exception + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 3: Validation error +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_validation_error(mock_db, test_client, admin_jwt): + response = test_client.delete( + "/api/bids/invalid_bid_id", headers={"Authorization": f"Bearer {admin_jwt}"} + ) + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'id': ['Invalid Id']}"} + + +# Case 4: Bid not found +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_not_found(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one_and_update.return_value = None + + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + mock_db["bids"].find_one_and_update.assert_called_once() + assert response.status_code == 404 + assert response.get_json() == {"Error": "Resource not found"} + + +# Case 5: Unauthorized - invalid token +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_unauthorized(mock_db, test_client): + mock_db["bids"].find_one_and_update.return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "status": "deleted", + } + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"}, + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} + + +# Case 6: Forbidden - not admin +@patch("api.controllers.bid_controller.current_app.db") +def test_delete_bid_forbidden(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one_and_update.return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "status": "deleted", + } + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 403 + assert response.get_json() == {"Error": "Forbidden"} diff --git a/tests/unit/test_delete_question.py b/tests/unit/test_delete_question.py new file mode 100644 index 0000000..55ecb54 --- /dev/null +++ b/tests/unit/test_delete_question.py @@ -0,0 +1,106 @@ +""" +This file contains the tests for the delete_question endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful hard delete question +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_success(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one_return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "links": { + "questions": "/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions", + "self": "/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + mock_db["bids"].find_one.assert_called_once_with( + {"_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9"} + ) + mock_db["questions"].delete_one.assert_called_once_with( + {"_id": "6e7d3f8a-fab3-4ebf-8348-96d0808d325e"} + ) + assert response.status_code == 204 + + +# Case 2: Failed to call database +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_connection_error(mock_db, test_client, admin_jwt): + mock_db["questions"].delete_one.side_effect = Exception + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 3: Validation error +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_validation_error(mock_db, test_client, admin_jwt): + response = test_client.delete( + "/api/bids/invalid-bid-id/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'id': ['Invalid Id']}"} + + +# Case 4: Related bid not found +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_bid_not_found(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one.return_value = None + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + + mock_db["bids"].find_one.assert_called_once_with( + {"_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9"} + ) + assert response.status_code == 404 + mock_db["questions"].delete_one.assert_not_called() + assert response.get_json() == {"Error": "Resource not found"} + + +# Case 5: Unauthorized - invalid token +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_unauthorized(mock_db, test_client): + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"}, + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} + + +# Case 6: Forbidden - not admin +@patch("api.controllers.question_controller.current_app.db") +def test_delete_question_forbidden(mock_db, test_client, basic_jwt): + response = test_client.delete( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 403 + assert response.get_json() == {"Error": "Forbidden"} + + +# # Case 7: Idempotence - question not found / already deleted +# @patch("api.controllers.question_controller.current_app.db") +# def test_delete_question_idempotence(mock_db, test_client, admin_jwt): +# response = test_client.delete( +# "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9/questions/6e7d3f8a-fab3-4ebf-8348-96d0808d325e", +# headers={"Authorization": f"Bearer {admin_jwt}"}, +# ) +# assert response.status_code == 204 diff --git a/tests/unit/test_get_bid_by_id.py b/tests/unit/test_get_bid_by_id.py new file mode 100644 index 0000000..83a43d2 --- /dev/null +++ b/tests/unit/test_get_bid_by_id.py @@ -0,0 +1,88 @@ +""" +This file contains the tests for the get_bid_by_id endpoint. +""" + +from unittest.mock import patch + + +# Case 1: Successful get_bid_by_id +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bid_by_id_success(mock_db, test_client, api_key): + mock_db["bids"].find_one.return_value = { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "links": { + "questions": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/questions", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + + response = test_client.get( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + mock_db["bids"].find_one.assert_called_once_with( + {"_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", "status": {"$ne": "deleted"}} + ) + assert response.status_code == 200 + assert response.get_json() == { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "links": { + "questions": "http://localhost:8080/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/questions", + "self": "http://localhost:8080/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + + +# Case 2: Connection error +@patch("api.controllers.bid_controller.current_app.db", side_effect=Exception) +def test_get_bid_by_id_connection_error(mock_db, test_client, api_key): + mock_db["bids"].find_one.side_effect = Exception + response = test_client.get( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 3: Bid not found +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bid_by_id_not_found(mock_db, test_client, api_key): + mock_db["bids"].find_one.return_value = None + + response = test_client.get( + "/api/bids/1ff45b42-b72a-464c-bde9-9bead14a07b9", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + mock_db["bids"].find_one.assert_called_once_with( + {"_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", "status": {"$ne": "deleted"}} + ) + assert response.status_code == 404 + assert response.get_json() == {"Error": "Resource not found"} + + +# Case 4: Validation error +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bid_by_id_validation_error(mock_db, test_client, api_key): + response = test_client.get( + "/api/bids/invalid_bid_id", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'id': ['Invalid Id']}"} diff --git a/tests/unit/test_get_bids.py b/tests/unit/test_get_bids.py new file mode 100644 index 0000000..76a85af --- /dev/null +++ b/tests/unit/test_get_bids.py @@ -0,0 +1,303 @@ +from unittest.mock import patch + + +# Case 1: Successful get +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_success(mock_db, test_client, api_key, default_limit, default_offset): + # Mock the find method of the db object + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + mock_db["bids"].find.return_value = sample_data + mock_db["bids"].count_documents.return_value = len(sample_data) + + response = test_client.get( + "/api/bids", # Provide correct query parameters + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # # Assert the response status code and content + assert response.status_code == 200 + response_data = response.get_json() + assert response_data["total_count"] == len(sample_data) + assert response_data["items"] == sample_data + assert response_data["limit"] == default_limit + assert response_data["offset"] == default_offset + + +# Case 2: Links prepended with hostname +@patch("api.controllers.bid_controller.current_app.db") +def test_links_with_host(mock_db, test_client, api_key): + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + mock_db["bids"].count_documents.return_value = len(sample_data) + + response = test_client.get( + "/api/bids", headers={"host": "localhost:8080", "X-API-Key": api_key} + ) + assert response.status_code == 200 + response_data = response.get_json() + assert response_data["total_count"] == len(sample_data) + assert response_data["items"] == sample_data + assert ( + response_data["items"][0]["links"]["bids"] + == "http://localhost:8080/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids" + ) + assert ( + response_data["items"][0]["links"]["self"] + == "http://localhost:8080/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301" + ) + + +# Case 3: Connection error +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_connection_error(mock_db, test_client, api_key): + mock_db["bids"].find.side_effect = Exception + response = test_client.get( + "/api/bids", headers={"host": "localhost:8080", "X-API-Key": api_key} + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 4: Unauthorized / invalid api key +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_unauthorized(mock_db, test_client): + response = test_client.get( + "/api/bids", headers={"host": "localhost:8080", "X-API-Key": "INVALID_API_KEY"} + ) + assert response.status_code == 401 + assert response.get_json()["Error"] == "Unauthorized" + + +# Case 5: Invalid offset - greater than maximum +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_max_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = int(max_offset) + 1 + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 6: Invalid offset - not a number +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_nan_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = "five" + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 7: Invalid offset - negative number +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_negative_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = -1 + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 8: Invalid limit - greater than maximum +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_max_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = int(max_limit) + 1 + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) + + +# Case 9: Invalid limit - not a number +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_nan_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = "five" + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) + + +# Case 10: Invalid limit - negative number +@patch("api.controllers.bid_controller.current_app.db") +def test_get_bids_negative_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = -1 + sample_data = [ + { + "_id": "1ff45b42-b72a-464c-bde9-9bead14a07b9", + "bid_date": "2023-06-23", + "client": "Office for National Statistics", + "links": { + "bids": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301/bids", + "self": "/bids/faaf8ef5-5db4-459d-8d24-bc39492e1301", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + } + ] + + mock_db["bids"].find.return_value = sample_data + + mock_db["bids"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the bids + response = test_client.get( + f"api/bids?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) diff --git a/tests/unit/test_get_question_by_id.py b/tests/unit/test_get_question_by_id.py new file mode 100644 index 0000000..7f4bb8e --- /dev/null +++ b/tests/unit/test_get_question_by_id.py @@ -0,0 +1,187 @@ +""" +This file contains the tests for the GET /bids/{bidId}/questions/{questionId} endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful get +@patch("api.controllers.question_controller.current_app.db") +def test_get_single_question_success(mock_db, test_client, api_key): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + + # Set up the sample data for a single question + sample_data = { + "_id": sample_question_id, + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/bids/{sample_bid_id}", + "self": f"/bids/{sample_bid_id}/questions/{sample_question_id}", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + + # Mock the database find_one method to return the sample question + mock_db["questions"].find_one.return_value = sample_data + + # Make a request to the endpoint to get the single question + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 200 + response_data = response.get_json() + assert response_data == sample_data + + +# Case 2: Links prepended with hostname +@patch("api.controllers.question_controller.current_app.db") +def test_single_question_links_with_host(mock_db, test_client, api_key): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + + # Set up the sample data for a single question + sample_data = { + "_id": sample_question_id, + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/bids/{sample_bid_id}", + "self": f"/bids/{sample_bid_id}/questions/{sample_question_id}", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + + # Mock the database find_one method to return the sample question + mock_db["questions"].find_one.return_value = sample_data + + # Make a request to the endpoint to get the single question with hostname in the headers + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 200 + response_data = response.get_json() + assert ( + response_data["links"]["bid"] == f"http://localhost:8080/bids/{sample_bid_id}" + ) + assert ( + response_data["links"]["self"] + == f"http://localhost:8080/bids/{sample_bid_id}/questions/{sample_question_id}" + ) + + +# Case 3: Connection error +@patch("api.controllers.question_controller.current_app.db") +def test_get_single_question_connection_error(mock_db, test_client, api_key): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + + # Mock the database find_one method to raise a ConnectionError + mock_db["questions"].find_one.side_effect = Exception + + # Make a request to the endpoint to get the single question + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 500 + response_data = response.get_json() + assert response_data == {"Error": "Could not connect to database"} + + +# Case 4: Unauthorized / invalid api key +@patch("api.controllers.question_controller.current_app.db") +def test_get_single_question_unauthorized(mock_db, test_client): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + + # Mock the database find_one method to return the question + mock_db["questions"].find_one.return_value = {} + + # Make a request to the endpoint to get the single question without providing a JWT + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080"}, + ) + + # Assert the response status code and content + assert response.status_code == 401 + response_data = response.get_json() + assert response_data == {"Error": "Unauthorized"} + + # Make a request to the endpoint to get the single question with an invalid JWT + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": "Bearer INVALID_JWT"}, + ) + + # Assert the response status code and content + assert response.status_code == 401 + response_data = response.get_json() + assert response_data == {"Error": "Unauthorized"} + + +# Case 5: No question found for the given ID +@patch("api.controllers.question_controller.current_app.db") +def test_no_question_found_by_id(mock_db, test_client, api_key): + # Set up the sample question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + + # Mock the database find_one method to return None (no question found) + mock_db["questions"].find_one.return_value = [] + + # Make a request to the endpoint to get the question by ID + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 404 + response_data = response.get_json() + assert response_data == {"Error": "Resource not found"} + + +# Case 6: Validation error +@patch("api.controllers.question_controller.current_app.db") +def test_get_question_by_id_validation_error(mock_db, test_client, api_key): + # Set up the sample question ID + sample_bid_id = "Invalid bid Id" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + response = test_client.get( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'id': ['Invalid Id']}"} diff --git a/tests/unit/test_get_questions.py b/tests/unit/test_get_questions.py new file mode 100644 index 0000000..c5e4301 --- /dev/null +++ b/tests/unit/test_get_questions.py @@ -0,0 +1,455 @@ +""" +This file contains tests for the GET /bids/{bid_id}/questions endpoint. +""" + +from unittest.mock import patch + + +# Case 1: Successful get +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_success( + mock_db, test_client, api_key, default_limit, default_offset +): + # Set up the sample data and expected result + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 200 + response_data = response.get_json() + assert response_data["total_count"] == len(sample_data) + assert response_data["items"] == sample_data + assert response_data["limit"] == default_limit + assert response_data["offset"] == default_offset + + +# Case 2: Links prepended with hostname +@patch("api.controllers.question_controller.current_app.db") +def test_links_with_host(mock_db, test_client, api_key): + # Set up the sample data and expected result + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + ] + + # Mock the database find method to return the filtered sample data + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + response_data = response.get_json() + assert ( + response_data["items"][0]["links"]["bid"] + == f"http://localhost:8080/api/bids/{sample_bid_id}" + ) + + assert ( + response_data["items"][0]["links"]["self"] + == f"http://localhost:8080/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791" + ) + + +# Case 3: Connection error +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_connection_error(mock_db, test_client, api_key): + # Set up the sample bid ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + + # Mock the database find method to raise a ConnectionError + mock_db["questions"].find.side_effect = Exception + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 500 + response_data = response.get_json() + assert response_data == {"Error": "Could not connect to database"} + + +# Case 4: Unauthorized / invalid api key +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_unauthorized(mock_db, test_client): + # Set up the sample bid ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + + # Mock the database find method to return an empty list + mock_db["questions"].find.return_value = [] + + # Make a request to the endpoint to get the questions without providing a JWT + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", headers={"host": "localhost:8080"} + ) + + # Assert the response status code and content + assert response.status_code == 401 + response_data = response.get_json() + assert response_data == {"Error": "Unauthorized"} + + # Make a request to the endpoint to get the questions with an invalid JWT + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "Authorization": "Bearer INVALID_JWT"}, + ) + + # Assert the response status code and content + assert response.status_code == 401 + response_data = response.get_json() + assert response_data == {"Error": "Unauthorized"} + + +# Case 5: No questions found +@patch("api.controllers.question_controller.current_app.db") +def test_no_questions_found(mock_db, test_client, api_key): + # Set up the sample bid ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + + # Mock the database find method to return an empty list + mock_db["questions"].find.return_value = [] + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + # Assert the response status code and content + assert response.status_code == 404 + response_data = response.get_json() + assert response_data == {"Error": "Resource not found"} + + +# Case 6: Validation error +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_bid_id_validation_error(mock_db, test_client, api_key): + # Set up the sample question ID + sample_bid_id = "Invalid bid Id" + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + assert response.status_code == 400 + assert response.get_json() == {"Error": "{'id': ['Invalid Id']}"} + + +# Case 7: Invalid offset - greater than maximum +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_max_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = int(max_offset) + 1 + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 8: Invalid offset - not a number +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_nan_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = "five" + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 9: Invalid offset - negative number +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_negative_offset(mock_db, test_client, api_key, max_offset): + invalid_offset = -1 + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?offset={invalid_offset}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Offset value must be a number between 0 and {max_offset}" + ) + + +# Case 10: Invalid limit - greater than maximum +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_max_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = int(max_limit) + 1 + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) + + +# Case 11: Invalid limit - not a number +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_nan_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = "ten" + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) + + +# Case 12: Invalid limit - negative number +@patch("api.controllers.question_controller.current_app.db") +def test_get_questions_negative_limit(mock_db, test_client, api_key, max_limit): + invalid_limit = -1 + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_data = [ + { + "_id": "2b18f477-627f-4d48-a008-ca0d9cea3791", + "description": "This is a question", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "last_updated": "2023-08-01T23:11:59.336092", + "links": { + "bid": f"/api/bids/{sample_bid_id}", + "self": f"/api/bids/{sample_bid_id}/questions/2b18f477-627f-4d48-a008-ca0d9cea3791", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + }, + ] + + mock_db["questions"].find.return_value = sample_data + + mock_db["questions"].count_documents.return_value = len(sample_data) + + # Make a request to the endpoint to get the questions + response = test_client.get( + f"api/bids/{sample_bid_id}/questions?limit={invalid_limit}", + headers={"host": "localhost:8080", "X-API-Key": api_key}, + ) + + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == f"Limit value must be a number between 0 and {max_limit}" + ) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py new file mode 100644 index 0000000..14ed900 --- /dev/null +++ b/tests/unit/test_helpers.py @@ -0,0 +1,84 @@ +""" +This file contains tests for the helper functions in helpers.py +""" +import pytest +from helpers.helpers import ( + prepend_host_to_links, + validate_pagination, + validate_sort, +) + + +# Case 1: Host is prepended to values in links object +def test_prepend_host(): + resource = { + "_id": "9f688442-b535-4683-ae1a-a64c1a3b8616", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "last_updated": "2023-07-19T11:15:25.743340", + "links": { + "questions": "/api/bids/9f688442-b535-4683-ae1a-a64c1a3b8616/questions", + "self": "/api/bids/9f688442-b535-4683-ae1a-a64c1a3b8616", + }, + "status": "in_progress", + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + hostname = "localhost:8080" + + result = prepend_host_to_links(resource, hostname) + + assert result["links"] == { + "questions": "http://localhost:8080/api/bids/9f688442-b535-4683-ae1a-a64c1a3b8616/questions", + "self": "http://localhost:8080/api/bids/9f688442-b535-4683-ae1a-a64c1a3b8616", + } + + +# Case 2: pagination values are validated correctly +def test_validate_pagination(default_limit, max_limit, default_offset, max_offset): + valid_limit = 10 + valid_offset = 20 + nan_limit = "five" + nan_offset = "ten" + negative_limit = -5 + negative_offset = -10 + + assert validate_pagination(valid_limit, valid_offset) == (valid_limit, valid_offset) + assert validate_pagination(None, valid_offset) == (int(default_limit), valid_offset) + assert validate_pagination(valid_limit, None) == (valid_limit, int(default_offset)) + with pytest.raises( + ValueError, match=f"Limit value must be a number between 0 and {max_limit}" + ): + validate_pagination(nan_limit, valid_offset) + with pytest.raises( + ValueError, match=f"Limit value must be a number between 0 and {max_limit}" + ): + validate_pagination(negative_limit, valid_offset) + with pytest.raises( + ValueError, match=f"Offset value must be a number between 0 and {max_offset}" + ): + validate_pagination(valid_limit, nan_offset) + with pytest.raises( + ValueError, match=f"Offset value must be a number between 0 and {max_offset}" + ): + validate_pagination(valid_limit, negative_offset) + + +# Case 3: sort value is validated correctly +def test_validate_sort(default_sort_bids, default_sort_questions): + valid_field_asc = "last_updated" + valid_field_desc = "-last_updated" + invalid_field = "invalid" + + assert validate_sort(valid_field_asc, "bids") == (valid_field_asc, 1) + assert validate_sort(valid_field_asc, "questions") == (valid_field_asc, 1) + assert validate_sort(valid_field_desc, "bids") == (valid_field_desc[1:], -1) + assert validate_sort(valid_field_desc, "questions") == (valid_field_desc[1:], -1) + assert validate_sort(None, "bids") == (default_sort_bids, 1) + assert validate_sort(None, "questions") == (default_sort_questions, 1) + with pytest.raises(ValueError, match="Invalid sort criteria"): + validate_sort(invalid_field, "bids") + with pytest.raises(ValueError, match="Invalid sort criteria"): + validate_sort(invalid_field, "questions") diff --git a/tests/unit/test_phase_schema.py b/tests/unit/test_phase_schema.py new file mode 100644 index 0000000..5d756e3 --- /dev/null +++ b/tests/unit/test_phase_schema.py @@ -0,0 +1,44 @@ +""" +This file contains tests for the phase schema. +""" +from unittest.mock import patch + + +# Case 1: score is mandatory when has_score is set to True +@patch("api.controllers.bid_controller.current_app.db") +def test_score_is_mandatory(mock_db, test_client, basic_jwt): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "success": [{"phase": 1, "has_score": True, "out_of": 36}], + } + + response = test_client.post( + "api/bids", json=data, headers={"Authorization": f"Bearer {basic_jwt}"} + ) + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == "{'success': {0: {'_schema': ['Score is mandatory when has_score is set to true.']}}}" + ) + + +# Case 2: out_of is mandatory when has_score is set to True +@patch("api.controllers.bid_controller.current_app.db") +def test_out_of_is_mandatory(mock_db, test_client, basic_jwt): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "failed": {"phase": 2, "has_score": True, "score": 20}, + } + + response = test_client.post( + "api/bids", json=data, headers={"Authorization": f"Bearer {basic_jwt}"} + ) + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == "{'failed': {'_schema': ['Out_of is mandatory when has_score is set to true.']}}" + ) diff --git a/tests/unit/test_post_bid.py b/tests/unit/test_post_bid.py new file mode 100644 index 0000000..56231d1 --- /dev/null +++ b/tests/unit/test_post_bid.py @@ -0,0 +1,114 @@ +""" +This file contains the tests for the POST /api/bids endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful post +@patch("api.controllers.bid_controller.current_app.db") +def test_post_is_successful(mock_db, test_client, basic_jwt): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "success": [{"phase": 1, "has_score": True, "out_of": 36, "score": 30}], + "failed": {"phase": 2, "has_score": True, "score": 20, "out_of": 36}, + } + + # Mock the behavior of db + mock_db["bids"].insert_one.return_value = data + + response = test_client.post( + "api/bids", json=data, headers={"Authorization": f"Bearer {basic_jwt}"} + ) + assert response.status_code == 201 + assert "_id" in response.get_json() and response.get_json()["_id"] is not None + assert ( + "tender" in response.get_json() + and response.get_json()["tender"] + == "Business Intelligence and Data Warehousing" + ) + assert ( + "client" in response.get_json() + and response.get_json()["client"] == "Office for National Statistics" + ) + assert ( + "last_updated" in response.get_json() + and response.get_json()["last_updated"] is not None + ) + assert ( + "bid_date" in response.get_json() + and response.get_json()["bid_date"] == "2023-06-21" + ) + + +# Case 2: Missing mandatory fields +@patch("api.controllers.bid_controller.current_app.db") +def test_field_missing(mock_db, test_client, basic_jwt): + data = {"client": "Sample Client", "bid_date": "2023-06-20"} + + response = test_client.post( + "api/bids", json=data, headers={"Authorization": f"Bearer {basic_jwt}"} + ) + assert response.status_code == 400 + assert response.get_json() == { + "Error": "{'tender': {'message': 'Missing mandatory field'}}" + } + + +# Case 3: Connection error +@patch("api.controllers.bid_controller.current_app.db") +def test_post_bid_connection_error(mock_db, test_client, basic_jwt): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "success": [{"phase": 1, "has_score": True, "out_of": 36, "score": 30}], + "failed": {"phase": 2, "has_score": True, "score": 20, "out_of": 36}, + } + # Mock the behavior of db + mock_db["bids"].insert_one.side_effect = Exception + response = test_client.post( + "/api/bids", json=data, headers={"Authorization": f"Bearer {basic_jwt}"} + ) + + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 4: Unauthorized - invalid token +@patch("api.controllers.bid_controller.current_app.db") +def test_post_bid_unauthorized(mock_db, test_client): + data = { + "tender": "Business Intelligence and Data Warehousing", + "client": "Office for National Statistics", + "bid_date": "2023-06-21", + "alias": "ONS", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Feedback from client in detail", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder/feedback", + }, + "success": [{"phase": 1, "has_score": True, "out_of": 36, "score": 30}], + "failed": {"phase": 2, "has_score": True, "score": 20, "out_of": 36}, + } + + # Mock the behavior of db + mock_db["bids"].insert_one.return_value = data + response = test_client.post( + "api/bids", json=data, headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"} + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} diff --git a/tests/unit/test_post_question.py b/tests/unit/test_post_question.py new file mode 100644 index 0000000..336c493 --- /dev/null +++ b/tests/unit/test_post_question.py @@ -0,0 +1,137 @@ +""" +This file contains the tests for the POST /bids//questions endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful post +@patch("api.controllers.question_controller.current_app.db") +def test_post_is_successful(mock_db, test_client, basic_jwt): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + + # Mock the behavior of db + mock_db["questions"].insert_one.return_value = data + + response = test_client.post( + "api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 201 + assert "_id" in response.get_json() and response.get_json()["_id"] is not None + assert ( + "description" in response.get_json() + and response.get_json()["description"] == "This is a question" + ) + assert ( + "question_url" in response.get_json() + and response.get_json()["question_url"] + == "https://organisation.sharepoint.com/Docs/dummyfolder" + ) + assert ( + "last_updated" in response.get_json() + and response.get_json()["last_updated"] is not None + ) + assert "feedback" in response.get_json() and response.get_json()["feedback"] == { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + } + + +# Case 2: Missing mandatory fields +@patch("api.controllers.question_controller.current_app.db") +def test_post_question_field_missing(mock_db, test_client, basic_jwt): + data = { + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + + response = test_client.post( + "api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 400 + assert response.get_json() == { + "Error": "{'description': {'message': 'Missing mandatory field'}}" + } + + +# Case 3: Connection error +@patch("api.controllers.question_controller.current_app.db") +def test_post_question_connection_error(mock_db, test_client, basic_jwt): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + # Mock the behavior of db + mock_db["questions"].insert_one.side_effect = Exception + response = test_client.post( + "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 4: Unauthorized - invalid token +@patch("api.controllers.question_controller.current_app.db") +def test_post_question_unauthorized(mock_db, test_client): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + + # Mock the behavior of db + mock_db["questions"].insert_one.return_value = data + response = test_client.post( + "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"}, + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} + + +# Case 5: Related bid not found +@patch("api.controllers.question_controller.current_app.db") +def test_post_question_bid_not_found(mock_db, test_client, basic_jwt): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + # Mock the behavior of db + mock_db["bids"].find_one.return_value = None + response = test_client.post( + "/api/bids/be15c306-c85b-4e67-a9f6-682553c065a1/questions", + json=data, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + + mock_db["bids"].find_one.assert_called_once() + assert response.status_code == 404 + assert response.get_json() == {"Error": "Resource not found"} diff --git a/tests/unit/test_question_schema.py b/tests/unit/test_question_schema.py new file mode 100644 index 0000000..ee4088b --- /dev/null +++ b/tests/unit/test_question_schema.py @@ -0,0 +1,110 @@ +""" +This module contains the schema for validating question data. +""" +import pytest +from marshmallow import ValidationError +from api.schemas.question_schema import QuestionSchema +from helpers.helpers import is_valid_uuid, is_valid_isoformat + + +# Case 1: New instance of bid model class generates expected fields +def test_question_model(): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + bid_id = "be15c306-c85b-4e67-a9f6-682553c065a1" + data["bid_id"] = bid_id + bid_document = QuestionSchema().load(data) + to_post = QuestionSchema().dump(bid_document) + + question_id = to_post["_id"] + # Test that UUID is generated and is valid UUID + assert to_post["_id"] is not None + assert is_valid_uuid(question_id) is True + # Test UUID validator + assert is_valid_uuid("99999") is False + + # Test that links object is generated and URLs are correct + assert to_post["links"] is not None + assert "self" in to_post["links"] + assert to_post["links"]["self"] == f"/api/bids/{bid_id}/questions/{question_id}" + assert "bid" in to_post["links"] + assert to_post["links"]["bid"] == f"/api/bids/{bid_id}" + + # Test that status is set to in_progress + assert to_post["status"] == "in_progress" + + # Test that last_updated field has been added and is valid + assert to_post["last_updated"] is not None + assert is_valid_isoformat(to_post["last_updated"]) is True + # Test ISOformat validator + assert is_valid_isoformat("07-06-2023") is False + + +# Case 2: Field validation - description +def test_validate_description(): + data = { + "description": 42, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + bid_id = "be15c306-c85b-4e67-a9f6-682553c065a1" + data["bid_id"] = bid_id + with pytest.raises(ValidationError): + QuestionSchema().load(data) + + +# Case 3: Field validation - question_url +def test_validate_question_url(): + data = { + "description": "This is a question", + "question_url": "Not a valid url", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + bid_id = "be15c306-c85b-4e67-a9f6-682553c065a1" + data["bid_id"] = bid_id + with pytest.raises(ValidationError): + QuestionSchema().load(data) + + +# Case 4: Field validation - feedback description +def test_validate_feedback_description(): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": 42, + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + } + bid_id = "be15c306-c85b-4e67-a9f6-682553c065a1" + data["bid_id"] = bid_id + with pytest.raises(ValidationError): + QuestionSchema().load(data) + + +# Case 5: Field validation - feedback url +def test_validate_feedback_url(): + data = { + "description": "This is a question", + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "feedback": { + "description": "Good feedback", + "url": "Not a valid url", + }, + } + bid_id = "be15c306-c85b-4e67-a9f6-682553c065a1" + data["bid_id"] = bid_id + with pytest.raises(ValidationError): + QuestionSchema().load(data) diff --git a/tests/unit/test_update_bid_by_id.py b/tests/unit/test_update_bid_by_id.py new file mode 100644 index 0000000..6f9d271 --- /dev/null +++ b/tests/unit/test_update_bid_by_id.py @@ -0,0 +1,216 @@ +""" +This file contains tests for the update_bid_by_id endpoint +""" +from unittest.mock import patch + + +# Case 1: Successful update +@patch("api.controllers.bid_controller.current_app.db") +def test_update_bid_by_id_success(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "9f688442-b535-4683-ae1a-a64c1a3b8616", + "tender": "Business Intelligence and Data Warehousing", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "was_successful": False, + } + + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"tender": "UPDATED TENDER"} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + mock_db["bids"].find_one.assert_called_once_with( + {"_id": bid_id, "status": "in_progress"} + ) + mock_db["bids"].replace_one.assert_called_once() + assert response.status_code == 200 + assert response.get_json()["tender"] == "UPDATED TENDER" + + +# Case 2: Invalid user input +@patch("api.controllers.bid_controller.current_app.db") +def test_input_validation(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = {"tender": 42} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 400 + assert response.get_json()["Error"] == "{'tender': ['Not a valid string.']}" + + +# Case 3: Bid not found +@patch("api.controllers.bid_controller.current_app.db") +def test_bid_not_found(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = None + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"tender": "Updated tender"} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 404 + assert response.get_json()["Error"] == "Resource not found" + + +# Case 4: Cannot update status +@patch("api.controllers.bid_controller.current_app.db") +def test_cannot_update_status(mock_db, test_client, basic_jwt): + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"status": "deleted"} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 422 + assert response.get_json()["Error"] == "Cannot update status" + + +# Case 5: Failed to call database +@patch("api.controllers.bid_controller.current_app.db") +def test_update_by_id_find_error(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "9f688442-b535-4683-ae1a-a64c1a3b8616", + "tender": "Business Intelligence and Data Warehousing", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "was_successful": False, + } + mock_db["bids"].find_one.side_effect = Exception + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"tender": "Updated tender"} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 6: Update failed field +@patch("api.controllers.bid_controller.current_app.db") +def test_update_failed(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = {"failed": {"phase": 2, "has_score": True, "score": 20, "out_of": 36}} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + mock_db["bids"].find_one.assert_called_once_with( + {"_id": bid_id, "status": "in_progress"} + ) + mock_db["bids"].replace_one.assert_called_once() + assert response.status_code == 200 + + +# Case 7: Update success field +@patch("api.controllers.bid_controller.current_app.db") +def test_update_success(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = { + "success": [ + {"phase": 1, "has_score": True, "score": 20, "out_of": 36}, + {"phase": 2, "has_score": True, "score": 20, "out_of": 36}, + ] + } + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + mock_db["bids"].find_one.assert_called_once_with( + {"_id": bid_id, "status": "in_progress"} + ) + mock_db["bids"].replace_one.assert_called_once() + assert response.status_code == 200 + + +# Case 8: Unauthorized - invalid token +@patch("api.controllers.bid_controller.current_app.db") +def test_update_bid_by_id_unauthorized(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "9f688442-b535-4683-ae1a-a64c1a3b8616", + "tender": "Business Intelligence and Data Warehousing", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "was_successful": False, + } + + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"tender": "UPDATED TENDER"} + response = test_client.put( + f"api/bids/{bid_id}", + json=update, + headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"}, + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} diff --git a/tests/unit/test_update_bid_status.py b/tests/unit/test_update_bid_status.py new file mode 100644 index 0000000..56d6d3e --- /dev/null +++ b/tests/unit/test_update_bid_status.py @@ -0,0 +1,184 @@ +""" +This file contains tests for the update_bid_status endpoint. +""" +from unittest.mock import patch + + +# Case 1: Successful update +@patch("api.controllers.bid_controller.current_app.db") +def test_update_bid_status_success(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = {"status": "completed"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + mock_db["bids"].find_one.assert_called_once_with({"_id": bid_id}) + mock_db["bids"].replace_one.assert_called_once() + assert response.status_code == 200 + assert response.get_json()["status"] == "completed" + + +# Case 2: Invalid status +@patch("api.controllers.bid_controller.current_app.db") +def test_invalid_status(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"status": "invalid"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 400 + assert ( + response.get_json()["Error"] + == "{'status': ['Must be one of: deleted, in_progress, completed.']}" + ) + + +# Case 3: Empty request body +@patch("api.controllers.bid_controller.current_app.db") +def test_empty_request(mock_db, test_client, admin_jwt): + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 422 + assert response.get_json()["Error"] == "Request must not be empty" + + +# Case 4: Bid not found +@patch("api.controllers.bid_controller.current_app.db") +def test_bid_not_found(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one.return_value = None + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"status": "completed"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 404 + assert response.get_json()["Error"] == "Resource not found" + + +# Case 5: Failed to call database +@patch("api.controllers.bid_controller.current_app.db") +def test_update_status_find_error(mock_db, test_client, admin_jwt): + mock_db["bids"].find_one.side_effect = Exception + bid_id = "9f688442-b535-4683-ae1a-a64c1a3b8616" + update = {"status": "completed"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 6: Unauthorized - invalid token +@patch("api.controllers.bid_controller.current_app.db") +def test_update_bid_status_unauthorized(mock_db, test_client): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = {"status": "completed"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": "Bearer N0tV4l1djsonW3Bt0K3n"}, + ) + assert response.status_code == 401 + assert response.get_json() == {"Error": "Unauthorized"} + + +# Case 7: Forbidden - not admin +@patch("api.controllers.bid_controller.current_app.db") +def test_update_bid_status_forbidden(mock_db, test_client, basic_jwt): + mock_db["bids"].find_one.return_value = { + "_id": "4141fac8-8879-4169-a46d-2effb1f515f6", + "alias": "ONS", + "bid_date": "2023-06-23", + "bid_folder_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "client": "Office for National Statistics", + "failed": None, + "feedback": None, + "last_updated": "2023-07-24T11:38:20.388019", + "links": { + "questions": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6/questions", + "self": "http://localhost:8080/bids/4141fac8-8879-4169-a46d-2effb1f515f6", + }, + "status": "in_progress", + "success": [], + "tender": "Business Intelligence and Data Warehousing", + "was_successful": False, + } + + bid_id = "4141fac8-8879-4169-a46d-2effb1f515f6" + update = {"status": "completed"} + response = test_client.put( + f"api/bids/{bid_id}/status", + json=update, + headers={"Authorization": f"Bearer {basic_jwt}"}, + ) + assert response.status_code == 403 + assert response.get_json() == {"Error": "Forbidden"} diff --git a/tests/unit/test_update_question.py b/tests/unit/test_update_question.py new file mode 100644 index 0000000..9e56402 --- /dev/null +++ b/tests/unit/test_update_question.py @@ -0,0 +1,171 @@ +""" +This file contains tests for the update_question endpoint. +""" +from unittest.mock import patch + + +# Case 1: Successful question update +@patch("api.controllers.question_controller.current_app.db") +def test_update_question_success(mock_db, test_client, basic_jwt): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + sample_updated_question = { + "_id": sample_question_id, + "description": "Updated question description", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "links": { + "bid": f"/bids/{sample_bid_id}", + "self": f"/bids/{sample_bid_id}/questions/{sample_question_id}", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + + # Mock the database find_one method to return the question data + mock_db["questions"].find_one.return_value = sample_updated_question + + # Mock the database replace_one method to return the updated question + mock_db["questions"].replace_one.return_value = sample_updated_question + + # Make a request to the endpoint to update the question + response = test_client.put( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"}, + json=sample_updated_question, + content_type="application/json", + ) + + # Assert the response status code and content + assert response.status_code == 200 + + response_data = response.get_json() + assert response_data["last_updated"] is not None + # Remove the 'last_updated' field from the response data before comparison + response_data.pop("last_updated", None) + assert response_data == sample_updated_question + + +# Case 2: Invalid user input +@patch("api.controllers.question_controller.current_app.db") +def test_update_question_invalid_input(mock_db, test_client, basic_jwt): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + mock_db["questions"].find_one.return_value = { + "_id": sample_question_id, + "description": "Updated question description", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "links": { + "bid": f"/bids/{sample_bid_id}", + "self": f"/bids/{sample_bid_id}/questions/{sample_question_id}", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + update = {"description": 42} + # Make a request to the endpoint to update the question with invalid data + response = test_client.put( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"}, + json=update, + content_type="application/json", + ) + + # Assert the response status code and content + assert response.status_code == 400 + response_data = response.get_json()["Error"] + assert response_data == "{'description': ['Not a valid string.']}" + + +# Case 3: Question not found +@patch("api.controllers.question_controller.current_app.db") +def test_question_not_found(mock_db, test_client, basic_jwt): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + mock_db["questions"].find_one.return_value = {} + + # Make a request to the endpoint to update the question with invalid data + response = test_client.put( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"}, + json={}, + content_type="application/json", + ) + # Assert the response status code and content + assert response.status_code == 404 + response_data = response.get_json()["Error"] + assert response_data == "Resource not found" + + +# Case 4: Exception handling - Internal Server Error +@patch("api.controllers.question_controller.current_app.db") +def test_exception_internal_server_error(mock_db, test_client, basic_jwt): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + # Mock the database find_one method to raise an Exception + mock_db["questions"].find_one.side_effect = Exception("Test Exception") + update = {"tender": "Updated tender"} + response = test_client.put( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"}, + json=update, + content_type="application/json", + ) + assert response.status_code == 500 + assert response.get_json() == {"Error": "Could not connect to database"} + + +# Case 5: Empty request body +@patch("api.controllers.question_controller.current_app.db") +def test_update_question_invalid_empty_request_body(mock_db, test_client, basic_jwt): + # Set up the sample bid ID and question ID + sample_bid_id = "66fb5dba-f129-413a-b12e-5a68b5a647d6" + sample_question_id = "2b18f477-627f-4d48-a008-ca0d9cea3791" + mock_db["questions"].find_one.return_value = { + "_id": sample_question_id, + "description": "Updated question description", + "feedback": { + "description": "Good feedback", + "url": "https://organisation.sharepoint.com/Docs/dummyfolder", + }, + "links": { + "bid": f"/bids/{sample_bid_id}", + "self": f"/bids/{sample_bid_id}/questions/{sample_question_id}", + }, + "out_of": None, + "question_url": "https://organisation.sharepoint.com/Docs/dummyfolder", + "respondents": [], + "response": None, + "score": None, + "status": "in_progress", + } + update = {} + # Make a request to the endpoint to update the question with invalid data + response = test_client.put( + f"api/bids/{sample_bid_id}/questions/{sample_question_id}", + headers={"host": "localhost:8080", "Authorization": f"Bearer {basic_jwt}"}, + json=update, + content_type="application/json", + ) + + # Assert the response status code and content + assert response.status_code == 422 + response_data = response.get_json()["Error"] + assert response_data == "Request must not be empty" diff --git a/tools.mk b/tools.mk new file mode 100644 index 0000000..1e6db23 --- /dev/null +++ b/tools.mk @@ -0,0 +1,44 @@ +.ONESHELL: + +TOPICS := fix - feat - docs - style - refactor - test - chore - build + +PYTHON = ./.venv/bin/python3 +PIP = ./.venv/bin/pip + +.PHONY: help branch check commit format lint + +help: + @echo "make helptools - display this help" + @echo "make branch - create a new branch" + @echo "make check - check for security vulnerabilities" + @echo "make commit - commit changes to git" + @echo "make format - format the code" + @echo "make lint - run linters" + +branch: + @echo "Available branch types:" + @echo "$(TOPICS)" + @read -p "Enter the branch type: " type; \ + read -p "Enter the branch description (kebab-case): " description; \ + git checkout -b $${type}/$${description}; \ + git push --set-upstream origin $${type}/$${description} + +check: + $(PIP) install safety + $(PIP) freeze | $(PYTHON) -m safety check --stdin + +commit: format + @echo "Available topics:" + @echo "$(TOPICS)" + @read -p "Enter the topic for the commit: " topic; \ + read -p "Enter the commit message: " message; \ + git add .; \ + git commit -m "$${topic}: $${message}"; \ + git push + +format: + $(PYTHON) -m black . + +lint: + $(PYTHON) -m flake8 + $(PYTHON) -m pylint **/*.py **/**/*.py *.py \ No newline at end of file