diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index b132620b8..11aec8fca 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -9,8 +9,7 @@ permissions: env: BASE_CACHE_SUFFIX: base - #BASE_BRANCH_NAME: ${{ github.event.pull_request.base.ref }} - BASE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + BASE_BRANCH_NAME: ${{ github.event.pull_request.base.ref }} BRANCH_NAME: ${{ github.event.pull_request.head.ref }} CI_ROLE_NAME: ${{ secrets.CI_ROLE_NAME }} BRANCH_GITHUB_SHA_SHORT: $(echo ${{ github.event.pull_request.head.sha }} | cut -c 1-7) diff --git a/CHANGELOG.md b/CHANGELOG.md index 149abb2ac..510d39d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2024-11-08 +- [PI-508] Search DeviceReferenceData +- [PI-578] Create MHS Device +- [PI-512] AS Interactions DeviceReferenceData + ## 2024-11-06 - [PI-593] Readme updates - [PI-594] More smoke tests diff --git a/VERSION b/VERSION index 91d72aef4..49194617a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2024.11.06 +2024.11.08 diff --git a/changelog/2024-11-08.md b/changelog/2024-11-08.md new file mode 100644 index 000000000..c884df301 --- /dev/null +++ b/changelog/2024-11-08.md @@ -0,0 +1,3 @@ +- [PI-508] Search DeviceReferenceData +- [PI-578] Create MHS Device +- [PI-512] AS Interactions DeviceReferenceData diff --git a/infrastructure/swagger/05_paths.yaml b/infrastructure/swagger/05_paths.yaml index 355dbd2b9..0cfdb8911 100644 --- a/infrastructure/swagger/05_paths.yaml +++ b/infrastructure/swagger/05_paths.yaml @@ -190,6 +190,26 @@ paths: - app-level0: [] /ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData: + get: + operationId: searchDeviceReferenceData + summary: Retrieve all Data Reference Data resources associated with a Product (GET) + parameters: + - $ref: "#/components/parameters/ProductTeamId" + - $ref: "#/components/parameters/ProductId" + - $ref: "#/components/parameters/HeaderVersion" + - $ref: "#/components/parameters/HeaderRequestId" + - $ref: "#/components/parameters/HeaderCorrelationId" + responses: + "200": + $ref: "#/components/responses/DeviceRefDataSearch" + "404": + $ref: "#/components/responses/NotFound" + x-amazon-apigateway-integration: + <<: *ApiGatewayIntegration + uri: ${method_searchDeviceReferenceData} + security: + - ${authoriser_name}: [] + - app-level0: [] post: operationId: createDeviceReferenceData summary: Create a Device Reference Data resource (POST) @@ -241,6 +261,32 @@ paths: - ${authoriser_name}: [] - app-level0: [] + ? /ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData/AccreditedSystemsAdditionalInteractions + : post: + operationId: createDeviceReferenceDataAdditionalInteractions + summary: Create a Device Reference Data Additional Interactions resource (POST) + parameters: + - $ref: "#/components/parameters/ProductTeamId" + - $ref: "#/components/parameters/ProductId" + - $ref: "#/components/parameters/HeaderVersion" + - $ref: "#/components/parameters/HeaderRequestId" + - $ref: "#/components/parameters/HeaderCorrelationId" + requestBody: + $ref: "#/components/requestBodies/DeviceReferenceDataAdditionalInteractionsCreateRequestBody" + responses: + "201": + $ref: "#/components/responses/DeviceRefDataCreate" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + x-amazon-apigateway-integration: + <<: *ApiGatewayIntegration + uri: ${method_createDeviceReferenceDataASActions} + security: + - ${authoriser_name}: [] + - app-level0: [] + ? /ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData/{device_reference_data_id} : get: operationId: readDeviceReferenceData @@ -313,6 +359,32 @@ paths: - ${authoriser_name}: [] - app-level0: [] + ? /ProductTeam/{product_team_id}/Product/{product_id}/Device/MessageHandlingSystem + : post: + operationId: createDeviceMessageHandlingSystem + summary: Create a Message Handling System Device resource (POST) + parameters: + - $ref: "#/components/parameters/ProductTeamId" + - $ref: "#/components/parameters/ProductId" + - $ref: "#/components/parameters/HeaderVersion" + - $ref: "#/components/parameters/HeaderRequestId" + - $ref: "#/components/parameters/HeaderCorrelationId" + requestBody: + $ref: "#/components/requestBodies/MessageHandlingSystemDeviceCreateRequestBody" + responses: + "201": + $ref: "#/components/responses/MhsDeviceCreate" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + x-amazon-apigateway-integration: + <<: *ApiGatewayIntegration + uri: ${method_createDeviceMessageHandlingSystem} + security: + - ${authoriser_name}: [] + - app-level0: [] + /searchSdsDevice: get: operationId: searchsdsdevice diff --git a/infrastructure/swagger/07_components--schemas--domain.yaml b/infrastructure/swagger/07_components--schemas--domain.yaml index a99cb2faa..b3ce4f13e 100644 --- a/infrastructure/swagger/07_components--schemas--domain.yaml +++ b/infrastructure/swagger/07_components--schemas--domain.yaml @@ -14,7 +14,7 @@ components: message: type: string StatusOK: - type: object + type: string properties: code: type: string @@ -38,8 +38,10 @@ components: type: string updated_on: type: string + nullable: true deleted_on: type: string + nullable: true keys: type: array items: @@ -86,8 +88,10 @@ components: type: string updated_on: type: string + nullable: true deleted_on: type: string + nullable: true example: id: "123-XXX" name: "Sample Product Team" @@ -115,7 +119,7 @@ components: items: $ref: "#/components/schemas/ProductResponse" example: - result: + results: - id: "123-XXX" name: "Sample Product Team" product_team_id: "F5H1R.55e86121-3826-468c-a6f0-dd0f1fbc0259" @@ -147,8 +151,45 @@ components: type: string updated_on: type: string + nullable: true deleted_on: type: string + nullable: true + keys: + type: array + items: + type: object + properties: + key_type: + type: string + key_value: + type: string + questionnaire_responses: + type: object + + MhsDeviceResponse: + type: object + properties: + id: + type: string + name: + type: string + product_id: + type: string + product_team_id: + type: string + ods_code: + type: string + status: + type: string + created_on: + type: string + updated_on: + type: string + nullable: true + deleted_on: + type: string + nullable: true keys: type: array items: @@ -160,12 +201,12 @@ components: type: string questionnaire_responses: type: object - properties: DeviceSearchResponse: type: array items: $ref: "#/components/schemas/DeviceResponse" + DeviceReferenceDataResponse: type: object properties: @@ -185,7 +226,42 @@ components: type: string updated_on: type: string + nullable: true deleted_on: type: string + nullable: true questionnaire_responses: type: object + example: + id: "55e86121-3826-468c-a6f0-dd0f1fbc0259" + name: "Sample DeviceReferenceDataResponse" + product_id: "123-XXX" + product_team_id: "F5H1R.55e86121-3826-468c-a6f0-dd0f1fbc0259" + ods_code: "F5H1R" + questionnaire_responses: + - key_type: "foo" + key_value: "bar" + created_on: "2025-10-15T10:00:00Z" + updated_on: "null" + deleted_on: "null" + + DeviceRefDataSearchResponse: + type: object + properties: + result: + type: array + items: + $ref: "#/components/schemas/DeviceReferenceDataResponse" + example: + results: + - id: "55e86121-3826-468c-a6f0-dd0f1fbc0259" + name: "Sample Device Reference Data" + product_id: "123-XXX" + product_team_id: "F5H1R.55e86121-3826-468c-a6f0-dd0f1fbc0259" + ods_code: "F5H1R" + questionnaire_responses: + - key_type: "foo" + key_value: "bar" + created_on: "2024-10-15T10:00:00Z" + updated_on: "null" + deleted_on: "null" diff --git a/infrastructure/swagger/11_components--requestBodies.yaml b/infrastructure/swagger/11_components--requestBodies.yaml index 7168f2322..93da851a7 100644 --- a/infrastructure/swagger/11_components--requestBodies.yaml +++ b/infrastructure/swagger/11_components--requestBodies.yaml @@ -87,6 +87,26 @@ components: description: Questionnaire Responses required: - questionnaire_responses + DeviceReferenceDataAdditionalInteractionsCreateRequestBody: + description: Request body to create a Device Reference Data Additional Interactions object + required: true + content: + application/json: + schema: + type: object + properties: + questionnaire_responses: + type: object + description: Questionnaire Responses for Additional Interactions questionnaire + properties: + spine_as_additional_interactions: + type: array + description: List of questionnaires associated with the additional interactions + items: + type: object + description: Questionnaire Responses + required: + - questionnaire_responses DeviceCreateRequestBody: description: Request body to create a Device object required: true @@ -102,3 +122,25 @@ components: - name example: name: "Sample Device" + MessageHandlingSystemDeviceCreateRequestBody: + description: Request body to create a Message Handling System Device object + required: true + content: + application/json: + schema: + type: object + properties: + questionnaire_responses: + type: object + description: Questionnaire Responses for MHS Device + properties: + spine_mhs: + type: array + description: spine_mhs questionnaire associated with the mhs device + items: + type: object + description: spine_mhs questionnaire response + required: + - questionnaire_responses + example: + questionnaire_responses: { "spine_mhs": [{ "question": "answer" }] } diff --git a/infrastructure/swagger/12_components--responses.yaml b/infrastructure/swagger/12_components--responses.yaml index 7a8e41c81..adfc24a0a 100644 --- a/infrastructure/swagger/12_components--responses.yaml +++ b/infrastructure/swagger/12_components--responses.yaml @@ -42,11 +42,19 @@ components: errors: - code: "MISSING_VALUE" message: ": field required" + - code: "MISSING_VALUE" + message: "Failed to validate data against '': '' is a required property" ValidationError: value: errors: - code: "VALIDATION_ERROR" message: "Item already exists" + - code: "VALIDATION_ERROR" + message: "Expected only one response for the '' questionnaire" + - code: "VALIDATION_ERROR" + message: "Require a 'spine_mhs' questionnaire response to create a MHS Device" + - code: "VALIDATION_ERROR" + message: "Not an EPR Product: Cannot create MHS device for product without exactly one Party Key" SdsSearchDeviceBadRequest: description: searchSDSDevice Bad request content: @@ -139,6 +147,12 @@ components: application/json: schema: $ref: "#/components/schemas/DeviceReferenceDataResponse" + DeviceRefDataSearch: + description: Search Device Reference Data operation successful + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceRefDataSearchResponse" DeviceCreate: description: Create Device operation successful content: @@ -151,6 +165,12 @@ components: application/json: schema: $ref: "#/components/schemas/DeviceResponse" + MhsDeviceCreate: + description: Create Message Handling System Device operation successful + content: + application/json: + schema: + $ref: "#/components/schemas/MhsDeviceResponse" SdsDeviceSearch: description: Search Device operation successful content: diff --git a/infrastructure/terraform/per_workspace/main.tf b/infrastructure/terraform/per_workspace/main.tf index 72e844c57..fda706064 100644 --- a/infrastructure/terraform/per_workspace/main.tf +++ b/infrastructure/terraform/per_workspace/main.tf @@ -88,7 +88,7 @@ module "lambdas" { source = "./modules/api_worker/api_lambda" python_version = var.python_version name = each.key - lambda_name = "${local.project}--${replace(terraform.workspace, "_", "-")}--${replace(replace(each.key, "_", "-"), "DeviceReferenceData", "DeviceRefData")}" + lambda_name = "${local.project}--${replace(terraform.workspace, "_", "-")}--${replace(replace(replace(each.key, "_", "-"), "DeviceReferenceData", "DeviceRefData"), "MessageHandlingSystem", "MHS")}" //Compact will remove all nulls from the list and create a new one - this is because TF throws an error if there is a null item in the list. layers = concat( compact([for instance in module.layers : contains(var.api_lambda_layers, instance.name) ? instance.layer_arn : null]), diff --git a/infrastructure/terraform/per_workspace/modules/api_entrypoint/api_gateway/locals.tf b/infrastructure/terraform/per_workspace/modules/api_entrypoint/api_gateway/locals.tf index b2ec8be33..03f82eb6a 100644 --- a/infrastructure/terraform/per_workspace/modules/api_entrypoint/api_gateway/locals.tf +++ b/infrastructure/terraform/per_workspace/modules/api_entrypoint/api_gateway/locals.tf @@ -5,7 +5,7 @@ locals { } methods = [ for lambda_alias in setsubtract(var.lambdas, ["authoriser"]) : - { "method_${lambda_alias}" = "${local.apigateway_lambda_arn_prefix}:${var.assume_account}:function:${var.project}--${replace(terraform.workspace, "_", "-")}--${replace(replace(lambda_alias, "_", "-"), "DeviceReferenceData", "DeviceRefData")}/invocations" } + { "method_${lambda_alias}" = "${local.apigateway_lambda_arn_prefix}:${var.assume_account}:function:${var.project}--${replace(terraform.workspace, "_", "-")}--${replace(replace(replace(lambda_alias, "_", "-"), "DeviceReferenceData", "DeviceRefData"), "MessageHandlingSystem", "MHS")}/invocations" } ] swagger_file = templatefile("${path.root}/../../swagger/dist/aws/swagger.yaml", merge({ lambda_invoke_arn = var.authoriser_metadata.lambda_invoke_arn, diff --git a/pyproject.toml b/pyproject.toml index c8068f60e..3bd6ea745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "connecting-party-manager" -version = "2024.11.06" +version = "2024.11.08" description = "Repository for the Connecting Party Manager API and related services" authors = ["NHS England"] license = "LICENSE.md" diff --git a/scripts/infrastructure/swagger/merge.sh b/scripts/infrastructure/swagger/merge.sh index 74efbe876..314df2c19 100644 --- a/scripts/infrastructure/swagger/merge.sh +++ b/scripts/infrastructure/swagger/merge.sh @@ -47,9 +47,7 @@ function _02_clean(){ yq 'del(.x-ibm-configuration)' | yq 'del(.components.schemas.*.discriminator)' | yq 'explode(.)' | - yq '(.. | select(style == "single")) style |= "double"' | - # Remove null dead-ends - yq 'del(.. | select(. == null))' \ + yq '(.. | select(style == "single")) style |= "double"' \ > ${_02_CLEAN_FILE} validate_yaml ${_02_CLEAN_FILE} } diff --git a/src/api/createDeviceMessageHandlingSystem/index.py b/src/api/createDeviceMessageHandlingSystem/index.py new file mode 100644 index 000000000..c2a0bfcfd --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/index.py @@ -0,0 +1,26 @@ +from api_utils.api_step_chain import execute_step_chain +from event.aws.client import dynamodb_client +from event.environment import BaseEnvironment +from event.logging.logger import setup_logger + +from .src.v1.steps import steps as v1_steps + + +class Environment(BaseEnvironment): + DYNAMODB_TABLE: str + + +versioned_steps = {"1": v1_steps} +cache = { + **Environment.build().dict(), + "DYNAMODB_CLIENT": dynamodb_client(), +} + + +def handler(event: dict, context=None): + setup_logger(service_name=__file__) + return execute_step_chain( + event=event, + cache=cache, + versioned_steps=versioned_steps, + ) diff --git a/src/api/createDeviceMessageHandlingSystem/make/make.py b/src/api/createDeviceMessageHandlingSystem/make/make.py new file mode 100644 index 000000000..f33cd7068 --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/make/make.py @@ -0,0 +1,4 @@ +from builder.lambda_build import build + +if __name__ == "__main__": + build(__file__) diff --git a/src/api/createDeviceMessageHandlingSystem/policies/dynamodb.json b/src/api/createDeviceMessageHandlingSystem/policies/dynamodb.json new file mode 100644 index 000000000..ba0648f13 --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/policies/dynamodb.json @@ -0,0 +1,6 @@ +[ + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:GetItem" +] diff --git a/src/api/createDeviceMessageHandlingSystem/policies/kms.json b/src/api/createDeviceMessageHandlingSystem/policies/kms.json new file mode 100644 index 000000000..f34357e28 --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/policies/kms.json @@ -0,0 +1 @@ +["kms:Decrypt"] diff --git a/src/api/createDeviceMessageHandlingSystem/src/v1/steps.py b/src/api/createDeviceMessageHandlingSystem/src/v1/steps.py new file mode 100644 index 000000000..c307f0f9c --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/src/v1/steps.py @@ -0,0 +1,109 @@ +from http import HTTPStatus + +from domain.api.common_steps.general import parse_event_body +from domain.api.common_steps.read_product import ( + get_party_key, + parse_path_params, + read_product, + read_product_team, +) +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.device.v3 import Device +from domain.core.error import InvalidSpineMhsResponse +from domain.core.questionnaire.v3 import Questionnaire, QuestionnaireResponse +from domain.repository.device_repository.v3 import DeviceRepository +from domain.repository.questionnaire_repository.v2 import QuestionnaireRepository +from domain.repository.questionnaire_repository.v2.questionnaires import ( + QuestionnaireInstance, +) +from domain.request_models.v1 import CreateMhsDeviceIncomingParams +from domain.response.validation_errors import mark_validation_errors_as_inbound + + +@mark_validation_errors_as_inbound +def parse_mhs_device_payload(data, cache) -> Device: + payload: dict = data[parse_event_body] + return CreateMhsDeviceIncomingParams(**payload) + + +def read_spine_mhs_questionnaire(data, cache) -> Questionnaire: + return QuestionnaireRepository().read(QuestionnaireInstance.SPINE_MHS) + + +def validate_spine_mhs_questionnaire_response(data, cache) -> QuestionnaireResponse: + spine_mhs_questionnaire: Questionnaire = data[read_spine_mhs_questionnaire] + payload: CreateMhsDeviceIncomingParams = data[parse_mhs_device_payload] + questionnaire_responses = payload.questionnaire_responses + + # Ensure there's a questionnaire named 'spine_mhs' in the responses + if QuestionnaireInstance.SPINE_MHS not in questionnaire_responses: + raise InvalidSpineMhsResponse( + "Require a 'spine_mhs' questionnaire response to create a MHS Device" + ) + + raw_spine_mhs_questionnaire_response = payload.questionnaire_responses[ + QuestionnaireInstance.SPINE_MHS + ] + # Ensure there's only one response to 'spine_mhs' + if len(raw_spine_mhs_questionnaire_response) != 1: + raise InvalidSpineMhsResponse( + "Expected only one response for the 'spine_mhs' questionnaire" + ) + + return spine_mhs_questionnaire.validate( + data=raw_spine_mhs_questionnaire_response[0] + ) + + +def create_mhs_device(data, cache) -> Device: + product: CpmProduct = data[read_product] + payload: CreateMhsDeviceIncomingParams = data[parse_mhs_device_payload] + + # Create a new Device dictionary excluding 'questionnaire_responses' + device_payload = payload.dict(exclude={"questionnaire_responses"}) + return product.create_device(**device_payload) + + +def create_party_key_tag(data, cache): + mhs_device: Device = data[create_mhs_device] + mhs_device.add_tag(party_key=data[get_party_key]) + return mhs_device + + +def add_spine_mhs_questionnaire_response(data, cache) -> list[QuestionnaireResponse]: + spine_mhs_questionnaire_response: QuestionnaireResponse = data[ + validate_spine_mhs_questionnaire_response + ] + mhs_device: Device = data[create_party_key_tag] + mhs_device.add_questionnaire_response(spine_mhs_questionnaire_response) + return mhs_device + + +def write_device(data: dict[str, CpmProduct], cache) -> CpmProduct: + mhs_device: Device = data[add_spine_mhs_questionnaire_response] + repo = DeviceRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + return repo.write(mhs_device) + + +def set_http_status(data, cache) -> tuple[HTTPStatus, str]: + device: Device = data[create_mhs_device] + return HTTPStatus.CREATED, device.state_exclude_tags() + + +steps = [ + parse_event_body, + parse_path_params, + parse_mhs_device_payload, + read_product_team, + read_product, + get_party_key, + read_spine_mhs_questionnaire, + validate_spine_mhs_questionnaire_response, + create_mhs_device, + create_party_key_tag, + add_spine_mhs_questionnaire_response, + write_device, + set_http_status, +] diff --git a/src/api/createDeviceMessageHandlingSystem/tests/test_index.py b/src/api/createDeviceMessageHandlingSystem/tests/test_index.py new file mode 100644 index 000000000..f70dfc0b4 --- /dev/null +++ b/src/api/createDeviceMessageHandlingSystem/tests/test_index.py @@ -0,0 +1,275 @@ +import json +import os +from contextlib import contextmanager +from datetime import datetime +from types import ModuleType +from typing import Any, Generator +from unittest import mock + +import pytest +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.cpm_system_id.v1 import ProductId +from domain.core.device.v3 import Device +from domain.core.product_key.v1 import ProductKeyType +from domain.core.root.v3 import Root +from domain.repository.cpm_product_repository.v3 import CpmProductRepository +from domain.repository.device_repository.v3 import DeviceRepository +from domain.repository.product_team_repository.v2 import ProductTeamRepository +from event.json import json_loads + +from test_helpers.dynamodb import mock_table +from test_helpers.uuid import consistent_uuid + +TABLE_NAME = "hiya" +DEVICE_NAME = "Product-MHS" +ODS_CODE = "AAA" +PRODUCT_ID = ProductId.create() +PRODUCT_TEAM_NAME = "My Product Team" +PRODUCT_NAME = "My Product" +VERSION = 1 + +QUESTIONNAIRE_DATA = { + "Address": "http://example.com", + "Unique Identifier": "123456", + "Managing Organization": "Example Org", + "MHS Party key": "party-key-001", + "MHS CPA ID": "cpa-id-001", + "Approver URP": "approver-123", + "Contract Property Template Key": "contract-key-001", + "Date Approved": "2024-01-01", + "Date DNS Approved": "2024-01-02", + "Date Requested": "2024-01-03", + "DNS Approver": "dns-approver-456", + "Interaction Type": "FHIR", + "MHS FQDN": "mhs.example.com", + "MHS Is Authenticated": "PERSISTENT", + "Product Key": "product-key-001", + "Requestor URP": "requestor-789", +} + + +@contextmanager +def mock_epr_product() -> Generator[tuple[ModuleType, CpmProduct], Any, None]: + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team(name=PRODUCT_TEAM_NAME) + + with mock_table(table_name=TABLE_NAME) as client, mock.patch.dict( + os.environ, + {"DYNAMODB_TABLE": TABLE_NAME, "AWS_DEFAULT_REGION": "eu-west-2"}, + clear=True, + ): + product_team_repo = ProductTeamRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + product.add_key(key_type=ProductKeyType.PARTY_KEY, key_value="ABC1234-987654") + product_repo = CpmProductRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_repo.write(entity=product) + + import api.createDeviceMessageHandlingSystem.index as index + + index.cache["DYNAMODB_CLIENT"] = client + + yield index, product + + +@contextmanager +def mock_not_epr_product() -> Generator[tuple[ModuleType, CpmProduct], Any, None]: + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team(name=PRODUCT_TEAM_NAME) + + with mock_table(table_name=TABLE_NAME) as client, mock.patch.dict( + os.environ, + {"DYNAMODB_TABLE": TABLE_NAME, "AWS_DEFAULT_REGION": "eu-west-2"}, + clear=True, + ): + product_team_repo = ProductTeamRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + product_repo = CpmProductRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_repo.write(entity=product) + + import api.createDeviceMessageHandlingSystem.index as index + + index.cache["DYNAMODB_CLIENT"] = client + + yield index, product + + +def test_index() -> None: + with mock_epr_product() as (index, product): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps( + {"questionnaire_responses": {"spine_mhs": [QUESTIONNAIRE_DATA]}} + ), + "pathParameters": { + "product_team_id": str(product.product_team_id), + "product_id": str(product.id), + }, + } + ) + + # Validate that the response indicates that a resource was created + assert response["statusCode"] == 201 + + _device = json_loads(response["body"]) + device = Device(**_device) + assert device.product_team_id == product.product_team_id + assert device.product_id == product.id + assert device.name == DEVICE_NAME + assert device.ods_code == ODS_CODE + assert device.created_on.date() == datetime.today().date() + assert device.updated_on.date() == datetime.today().date() + assert device.deleted_on is None + + questionnaire_responses = device.questionnaire_responses["spine_mhs/1"] + assert len(questionnaire_responses) == 1 + questionnaire_response = questionnaire_responses[0] + assert questionnaire_response.data == QUESTIONNAIRE_DATA + + # Retrieve the created resource + repo = DeviceRepository( + table_name=TABLE_NAME, dynamodb_client=index.cache["DYNAMODB_CLIENT"] + ) + created_device = repo.read(device.id) + + # Check party_key is added to tags in the created device + expected_party_key = (str(ProductKeyType.PARTY_KEY), "abc1234-987654") + assert any(expected_party_key in tag.__root__ for tag in created_device.tags) + + +@pytest.mark.parametrize( + ["body", "path_parameters", "error_code", "status_code"], + [ + ( + {}, + {"product_team_id": consistent_uuid(1)}, + "MISSING_VALUE", + 400, + ), + ( + {"questionnaire_responses": {"spine_mhs": [QUESTIONNAIRE_DATA]}}, + {}, + "MISSING_VALUE", + 400, + ), + ( + { + "questionnaire_responses": {"spine_mhs": [QUESTIONNAIRE_DATA]}, + "forbidden_extra_param": "foo", + }, + {"product_id": str(PRODUCT_ID), "product_team_id": consistent_uuid(1)}, + "VALIDATION_ERROR", + 400, + ), + ( + {"questionnaire_responses": {"spine_mhs": [QUESTIONNAIRE_DATA]}}, + { + "product_id": str(PRODUCT_ID), + "product_team_id": "id_that_does_not_exist", + }, + "RESOURCE_NOT_FOUND", + 404, + ), + ], +) +def test_incoming_errors(body, path_parameters, error_code, status_code): + with mock_epr_product() as (index, _): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps(body), + "pathParameters": path_parameters, + } + ) + + # Validate that the response indicates that the expected error occured + assert response["statusCode"] == status_code + assert error_code in response["body"] + + +@pytest.mark.parametrize( + ["body", "error_code", "error_message", "status_code"], + [ + ( + { + "questionnaire_responses": { + "spine_mhs": [QUESTIONNAIRE_DATA, QUESTIONNAIRE_DATA] + }, + }, + "VALIDATION_ERROR", + "Expected only one response for the 'spine_mhs' questionnaire", + 400, + ), + ( + { + "questionnaire_responses": { + "spine_mhs": [{"Address": "http://example.com"}] + } + }, + "MISSING_VALUE", + "Failed to validate data against 'spine_mhs/1': 'Unique Identifier' is a required property", + 400, + ), + ], +) +def test_questionnaire_response_validation_errors( + body, error_code, error_message, status_code +): + with mock_epr_product() as (index, product): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps(body), + "pathParameters": { + "product_team_id": str(product.product_team_id), + "product_id": str(product.id), + }, + } + ) + + # Validate that the response indicates that the expected error occured + assert response["statusCode"] == status_code + assert error_code in response["body"] + assert error_message in response["body"] + + +def test_not_epr_product(): + with mock_not_epr_product() as (index, product): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps( + {"questionnaire_responses": {"spine_mhs": [QUESTIONNAIRE_DATA]}} + ), + "pathParameters": { + "product_team_id": str(product.product_team_id), + "product_id": str(product.id), + }, + } + ) + + assert response["statusCode"] == 400 + expected_error_code = "VALIDATION_ERROR" + expected_message_code = "Not an EPR Product: Cannot create MHS device for product without exactly one Party Key" + assert expected_error_code in response["body"] + assert expected_message_code in response["body"] diff --git a/src/api/createDeviceReferenceDataASActions/index.py b/src/api/createDeviceReferenceDataASActions/index.py new file mode 100644 index 000000000..c2a0bfcfd --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/index.py @@ -0,0 +1,26 @@ +from api_utils.api_step_chain import execute_step_chain +from event.aws.client import dynamodb_client +from event.environment import BaseEnvironment +from event.logging.logger import setup_logger + +from .src.v1.steps import steps as v1_steps + + +class Environment(BaseEnvironment): + DYNAMODB_TABLE: str + + +versioned_steps = {"1": v1_steps} +cache = { + **Environment.build().dict(), + "DYNAMODB_CLIENT": dynamodb_client(), +} + + +def handler(event: dict, context=None): + setup_logger(service_name=__file__) + return execute_step_chain( + event=event, + cache=cache, + versioned_steps=versioned_steps, + ) diff --git a/src/api/createDeviceReferenceDataASActions/make/make.py b/src/api/createDeviceReferenceDataASActions/make/make.py new file mode 100644 index 000000000..f33cd7068 --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/make/make.py @@ -0,0 +1,4 @@ +from builder.lambda_build import build + +if __name__ == "__main__": + build(__file__) diff --git a/src/api/createDeviceReferenceDataASActions/policies/dynamodb.json b/src/api/createDeviceReferenceDataASActions/policies/dynamodb.json new file mode 100644 index 000000000..7e63e9c6b --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/policies/dynamodb.json @@ -0,0 +1,6 @@ +[ + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:UpdateItem" +] diff --git a/src/api/createDeviceReferenceDataASActions/policies/kms.json b/src/api/createDeviceReferenceDataASActions/policies/kms.json new file mode 100644 index 000000000..f34357e28 --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/policies/kms.json @@ -0,0 +1 @@ +["kms:Decrypt"] diff --git a/src/api/createDeviceReferenceDataASActions/src/v1/steps.py b/src/api/createDeviceReferenceDataASActions/src/v1/steps.py new file mode 100644 index 000000000..583c915f8 --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/src/v1/steps.py @@ -0,0 +1,142 @@ +from http import HTTPStatus + +from domain.api.common_steps.general import parse_event_body +from domain.api.common_steps.read_product import ( + parse_path_params, + read_product, + read_product_team, +) +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.device_reference_data.v1 import DeviceReferenceData +from domain.core.error import ConfigurationError +from domain.core.product_key.v1 import ProductKeyType +from domain.core.questionnaire.v3 import Questionnaire, QuestionnaireResponse +from domain.repository.device_reference_data_repository.v1 import ( + DeviceReferenceDataRepository, +) +from domain.repository.errors import AlreadyExistsError +from domain.repository.questionnaire_repository.v2 import QuestionnaireRepository +from domain.repository.questionnaire_repository.v2.questionnaires import ( + QuestionnaireInstance, +) +from domain.request_models.v1 import ( + CreateDeviceReferenceAdditionalInteractionsDataParams, +) +from domain.response.validation_errors import mark_validation_errors_as_inbound + +DEVICE_NAME_MARKER = "AS Additional Interactions" + + +@mark_validation_errors_as_inbound +def parse_device_reference_data_for_epr_payload( + data, cache +) -> CreateDeviceReferenceAdditionalInteractionsDataParams: + payload: dict = data[parse_event_body] + return CreateDeviceReferenceAdditionalInteractionsDataParams(**payload) + + +def get_party_key(data, cache) -> str: + product: CpmProduct = data[read_product] + party_keys = [ + key.key_value + for key in product.keys + if key.key_type is ProductKeyType.PARTY_KEY + ] + try: + (party_key,) = party_keys + except ValueError: + raise ConfigurationError( + "Not an EPR Product: Cannot create Additional Interactions in Product without exactly one Party Key" + ) + return party_key + + +def require_no_existing_additional_interactions_device_reference_data( + data, cache +) -> list[QuestionnaireResponse]: + product: CpmProduct = data[read_product] + repo = DeviceReferenceDataRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + results = repo.search( + product_team_id=product.product_team_id, product_id=product.id + ) + if any( + device_reference_data.name.endswith(DEVICE_NAME_MARKER) + for device_reference_data in results + ): + raise AlreadyExistsError( + "Additional Interactions Device Reference Data already exists for this Product" + ) + + +def read_questionnaire(data, cache) -> Questionnaire: + return QuestionnaireRepository().read( + QuestionnaireInstance.SPINE_AS_ADDITIONAL_INTERACTIONS + ) + + +def validate_questionnaire_responses(data, cache) -> list[QuestionnaireResponse]: + questionnaire: Questionnaire = data[read_questionnaire] + payload: CreateDeviceReferenceAdditionalInteractionsDataParams = data[ + parse_device_reference_data_for_epr_payload + ] + raw_questionnaire_responses = payload.questionnaire_responses[ + QuestionnaireInstance.SPINE_AS_ADDITIONAL_INTERACTIONS + ] + return [questionnaire.validate(data=qr) for qr in raw_questionnaire_responses] + + +def create_additional_interactions_device_reference_data( + data, cache +) -> DeviceReferenceData: + product: CpmProduct = data[read_product] + party_key: str = data[get_party_key] + return product.create_device_reference_data( + name=f"{party_key} - {DEVICE_NAME_MARKER}" + ) + + +def add_questionnaire_response(data, cache) -> list[QuestionnaireResponse]: + questionnaire_responses: list[QuestionnaireResponse] = data[ + validate_questionnaire_responses + ] + device_reference_data: DeviceReferenceData = data[ + create_additional_interactions_device_reference_data + ] + for qr in questionnaire_responses: + device_reference_data.add_questionnaire_response(qr) + + +def write_device_reference_data(data: dict[str, CpmProduct], cache) -> CpmProduct: + device_reference_data: DeviceReferenceData = data[ + create_additional_interactions_device_reference_data + ] + repo = DeviceReferenceDataRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + return repo.write(device_reference_data) + + +def set_http_status(data, cache) -> tuple[HTTPStatus, str]: + device_reference_data: DeviceReferenceData = data[ + create_additional_interactions_device_reference_data + ] + return HTTPStatus.CREATED, device_reference_data.state() + + +steps = [ + parse_event_body, + parse_path_params, + parse_device_reference_data_for_epr_payload, + read_product_team, + read_product, + get_party_key, + require_no_existing_additional_interactions_device_reference_data, + read_questionnaire, + validate_questionnaire_responses, + create_additional_interactions_device_reference_data, + add_questionnaire_response, + write_device_reference_data, + set_http_status, +] diff --git a/src/api/createDeviceReferenceDataASActions/tests/test_index.py b/src/api/createDeviceReferenceDataASActions/tests/test_index.py new file mode 100644 index 000000000..47036070b --- /dev/null +++ b/src/api/createDeviceReferenceDataASActions/tests/test_index.py @@ -0,0 +1,160 @@ +import json +import os +from contextlib import contextmanager +from datetime import datetime +from types import ModuleType +from typing import Any, Generator +from unittest import mock + +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.cpm_system_id.v1 import PartyKeyId, ProductId +from domain.core.device_reference_data.v1 import DeviceReferenceData +from domain.core.product_key.v1 import ProductKeyType +from domain.core.root.v3 import Root +from domain.repository.cpm_product_repository.v3 import CpmProductRepository +from domain.repository.device_reference_data_repository.v1 import ( + DeviceReferenceDataRepository, +) +from domain.repository.product_team_repository.v2 import ProductTeamRepository +from event.json import json_loads + +from test_helpers.dynamodb import mock_table + +TABLE_NAME = "hiya" + +ODS_CODE = "AAA" +PRODUCT_ID = ProductId.create() +PRODUCT_NAME = "My Product" +VERSION = 1 + + +@contextmanager +def mock_product() -> Generator[tuple[ModuleType, CpmProduct], Any, None]: + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team(name=PRODUCT_NAME) + + with mock_table(table_name=TABLE_NAME) as client, mock.patch.dict( + os.environ, + {"DYNAMODB_TABLE": TABLE_NAME, "AWS_DEFAULT_REGION": "eu-west-2"}, + clear=True, + ): + product_team_repo = ProductTeamRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + product.add_key( + key_type=ProductKeyType.PARTY_KEY, + key_value=str(PartyKeyId.create(current_number=100000, ods_code="AAA")), + ) + product_repo = CpmProductRepository( + table_name=TABLE_NAME, dynamodb_client=client + ) + product_repo.write(entity=product) + + import api.createDeviceReferenceDataASActions.index as index + + index.cache["DYNAMODB_CLIENT"] = client + + yield index, product + + +def test_index_without_questionnaire() -> None: + with mock_product() as (index, product): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps({}), + "pathParameters": { + "product_team_id": str(product.product_team_id), + "product_id": str(product.id), + }, + } + ) + + # Validate that the response indicates that a resource was created + assert response["statusCode"] == 201 + + _device_reference_data = json_loads(response["body"]) + device_reference_data = DeviceReferenceData(**_device_reference_data) + assert device_reference_data.product_id == product.id + assert device_reference_data.product_team_id == product.product_team_id + assert device_reference_data.name == "AAA-100001 - AS Additional Interactions" + assert device_reference_data.ods_code == ODS_CODE + assert device_reference_data.created_on.date() == datetime.today().date() + assert device_reference_data.updated_on is None + assert device_reference_data.deleted_on is None + assert device_reference_data.questionnaire_responses == {} + + # Retrieve the created resource + repo = DeviceReferenceDataRepository( + table_name=TABLE_NAME, dynamodb_client=index.cache["DYNAMODB_CLIENT"] + ) + + created_device_reference_data = repo.read( + product_team_id=device_reference_data.product_team_id, + product_id=device_reference_data.product_id, + device_reference_data_id=device_reference_data.id, + ) + assert created_device_reference_data == device_reference_data + + +def test_index_with_questionnaire() -> None: + questionnaire_data = [ + {"Interaction ID": "foo"}, + {"Interaction ID": "bar"}, + ] + + with mock_product() as (index, product): + # Execute the lambda + response = index.handler( + event={ + "headers": {"version": VERSION}, + "body": json.dumps( + { + "questionnaire_responses": { + "spine_as_additional_interactions": questionnaire_data + } + } + ), + "pathParameters": { + "product_team_id": str(product.product_team_id), + "product_id": str(product.id), + }, + } + ) + + # Validate that the response indicates that a resource was created + assert response["statusCode"] == 201 + + _device_reference_data = json_loads(response["body"]) + device_reference_data = DeviceReferenceData(**_device_reference_data) + assert device_reference_data.product_id == product.id + assert device_reference_data.product_team_id == product.product_team_id + assert device_reference_data.name == "AAA-100001 - AS Additional Interactions" + assert device_reference_data.ods_code == ODS_CODE + assert device_reference_data.created_on.date() == datetime.today().date() + assert device_reference_data.updated_on.date() == datetime.today().date() + assert device_reference_data.deleted_on is None + + questionnaire_responses = device_reference_data.questionnaire_responses[ + "spine_as_additional_interactions/1" + ] + _questionnaire_data = [qr.data for qr in questionnaire_responses] + assert _questionnaire_data == questionnaire_data + + # Retrieve the created resource + repo = DeviceReferenceDataRepository( + table_name=TABLE_NAME, dynamodb_client=index.cache["DYNAMODB_CLIENT"] + ) + + created_device_reference_data = repo.read( + product_team_id=device_reference_data.product_team_id, + product_id=device_reference_data.product_id, + device_reference_data_id=device_reference_data.id, + ) + assert created_device_reference_data == device_reference_data diff --git a/src/api/createDeviceReferenceDataMessageSet/src/v1/steps.py b/src/api/createDeviceReferenceDataMessageSet/src/v1/steps.py index 45da32c58..d910e13ce 100644 --- a/src/api/createDeviceReferenceDataMessageSet/src/v1/steps.py +++ b/src/api/createDeviceReferenceDataMessageSet/src/v1/steps.py @@ -2,14 +2,13 @@ from domain.api.common_steps.general import parse_event_body from domain.api.common_steps.read_product import ( + get_party_key, parse_path_params, read_product, read_product_team, ) from domain.core.cpm_product.v1 import CpmProduct from domain.core.device_reference_data.v1 import DeviceReferenceData -from domain.core.error import ConfigurationError -from domain.core.product_key.v1 import ProductKeyType from domain.core.questionnaire.v3 import Questionnaire, QuestionnaireResponse from domain.repository.device_reference_data_repository.v1 import ( DeviceReferenceDataRepository, @@ -22,6 +21,8 @@ from domain.request_models.v1 import CreateDeviceReferenceMessageSetsDataParams from domain.response.validation_errors import mark_validation_errors_as_inbound +DEVICE_NAME_MARKER = "MHS Message Set" + @mark_validation_errors_as_inbound def parse_device_reference_data_for_epr_payload( @@ -31,22 +32,6 @@ def parse_device_reference_data_for_epr_payload( return CreateDeviceReferenceMessageSetsDataParams(**payload) -def get_party_key(data, cache) -> str: - product: CpmProduct = data[read_product] - party_keys = [ - key.key_value - for key in product.keys - if key.key_type is ProductKeyType.PARTY_KEY - ] - try: - (party_key,) = party_keys - except ValueError: - raise ConfigurationError( - "Cannot create Message Set in Product without exactly one Party Key" - ) - return party_key - - def require_no_existing_message_sets_device_reference_data( data, cache ) -> list[QuestionnaireResponse]: @@ -57,7 +42,10 @@ def require_no_existing_message_sets_device_reference_data( results = repo.search( product_team_id=product.product_team_id, product_id=product.id ) - if len(results) > 0: + if any( + device_reference_data.name.endswith(DEVICE_NAME_MARKER) + for device_reference_data in results + ): raise AlreadyExistsError( "This product already has a 'Message Set' DeviceReferenceData. " "Please update, or delete and recreate if you wish to make changes." @@ -82,7 +70,9 @@ def validate_questionnaire_responses(data, cache) -> list[QuestionnaireResponse] def create_message_set_device_reference_data(data, cache) -> DeviceReferenceData: product: CpmProduct = data[read_product] party_key: str = data[get_party_key] - return product.create_device_reference_data(name=f"{party_key} - MHS Message Set") + return product.create_device_reference_data( + name=f"{party_key} - {DEVICE_NAME_MARKER}" + ) def add_questionnaire_response(data, cache) -> list[QuestionnaireResponse]: diff --git a/src/api/searchDeviceReferenceData/index.py b/src/api/searchDeviceReferenceData/index.py new file mode 100644 index 000000000..142481a03 --- /dev/null +++ b/src/api/searchDeviceReferenceData/index.py @@ -0,0 +1,27 @@ +from api_utils.api_step_chain import execute_step_chain +from event.aws.client import dynamodb_client +from event.environment import BaseEnvironment +from event.logging.logger import setup_logger + +from .src.v1.steps import steps as v1_steps + + +class Environment(BaseEnvironment): + DYNAMODB_TABLE: str + + +versioned_steps = {"1": v1_steps} +cache = { + **Environment.build().dict(), + "DYNAMODB_CLIENT": dynamodb_client(), +} + + +def handler(event: dict, context=None): + setup_logger(service_name=__file__) + + return execute_step_chain( + event=event, + cache=cache, + versioned_steps=versioned_steps, + ) diff --git a/src/api/searchDeviceReferenceData/make/make.py b/src/api/searchDeviceReferenceData/make/make.py new file mode 100644 index 000000000..f33cd7068 --- /dev/null +++ b/src/api/searchDeviceReferenceData/make/make.py @@ -0,0 +1,4 @@ +from builder.lambda_build import build + +if __name__ == "__main__": + build(__file__) diff --git a/src/api/searchDeviceReferenceData/policies/dynamodb.json b/src/api/searchDeviceReferenceData/policies/dynamodb.json new file mode 100644 index 000000000..a1dd66409 --- /dev/null +++ b/src/api/searchDeviceReferenceData/policies/dynamodb.json @@ -0,0 +1 @@ +["dynamodb:Query"] diff --git a/src/api/searchDeviceReferenceData/policies/kms.json b/src/api/searchDeviceReferenceData/policies/kms.json new file mode 100644 index 000000000..f34357e28 --- /dev/null +++ b/src/api/searchDeviceReferenceData/policies/kms.json @@ -0,0 +1 @@ +["kms:Decrypt"] diff --git a/src/api/searchDeviceReferenceData/src/v1/steps.py b/src/api/searchDeviceReferenceData/src/v1/steps.py new file mode 100644 index 000000000..a0cb7e9cd --- /dev/null +++ b/src/api/searchDeviceReferenceData/src/v1/steps.py @@ -0,0 +1,61 @@ +from http import HTTPStatus + +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.product_team.v3 import ProductTeam +from domain.repository.cpm_product_repository.v3 import CpmProductRepository +from domain.repository.device_reference_data_repository.v1 import ( + DeviceReferenceDataRepository, +) +from domain.repository.product_team_repository.v2 import ProductTeamRepository +from domain.request_models.v1 import CpmProductPathParams +from domain.response.response_models import SearchResponse +from event.step_chain import StepChain + + +def parse_incoming_path_parameters(data, cache) -> CpmProductPathParams: + event = APIGatewayProxyEvent(data[StepChain.INIT]) + return CpmProductPathParams(**event.path_parameters) + + +def validate_product_team(data, cache) -> ProductTeam: + product_team_repo = ProductTeamRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + path_params: CpmProductPathParams = data[parse_incoming_path_parameters] + return product_team_repo.read(id=path_params.product_team_id) + + +def validate_product(data, cache) -> CpmProduct: + cpm_product_repo = CpmProductRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + path_params: CpmProductPathParams = data[parse_incoming_path_parameters] + return cpm_product_repo.read( + product_team_id=path_params.product_team_id, product_id=path_params.product_id + ) + + +def query_device_ref_data(data, cache) -> list[dict]: + product_team: ProductTeam = data[validate_product_team] + product: CpmProduct = data[validate_product] + drd_repo = DeviceReferenceDataRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + results = drd_repo.search(product_team_id=product_team.id, product_id=product.id) + return results + + +def return_device_ref_data(data, cache) -> tuple[HTTPStatus, dict]: + device_ref_data = data[query_device_ref_data] + response = SearchResponse(results=device_ref_data) + return HTTPStatus.OK, response.state() + + +steps = [ + parse_incoming_path_parameters, + validate_product_team, + validate_product, + query_device_ref_data, + return_device_ref_data, +] diff --git a/src/api/searchDeviceReferenceData/tests/test_index.py b/src/api/searchDeviceReferenceData/tests/test_index.py new file mode 100644 index 000000000..82762d6a3 --- /dev/null +++ b/src/api/searchDeviceReferenceData/tests/test_index.py @@ -0,0 +1,362 @@ +import json +import os +from unittest import mock + +import pytest +from domain.core.cpm_system_id import ProductId +from domain.core.root.v3 import Root +from domain.repository.cpm_product_repository.v3 import CpmProductRepository +from domain.repository.device_reference_data_repository import ( + DeviceReferenceDataRepository, +) +from domain.repository.product_team_repository.v2 import ProductTeamRepository +from event.aws.client import dynamodb_client +from event.json import json_loads + +from test_helpers.response_assertions import _response_assertions +from test_helpers.sample_data import CPM_PRODUCT_TEAM_NO_ID +from test_helpers.terraform import read_terraform_output +from test_helpers.validate_search_response import validate_product_result_body + +TABLE_NAME = "hiya" + + +def _create_org(): + org = Root.create_ods_organisation(ods_code=CPM_PRODUCT_TEAM_NO_ID["ods_code"]) + product_team = org.create_product_team( + name=CPM_PRODUCT_TEAM_NO_ID["name"], keys=CPM_PRODUCT_TEAM_NO_ID["keys"] + ) + return product_team + + +def _create_product(product, product_team): + generated_product_id = ProductId.create() + cpmproduct = product_team.create_cpm_product( + product_id=generated_product_id.id, name=product["name"] + ) + + return cpmproduct + + +def _create_device_ref_data(device_ref_data, product): + drd = product.create_device_reference_data(name=device_ref_data["name"]) + + return drd + + +@pytest.mark.parametrize( + "version", + [ + "1", + ], +) +@pytest.mark.integration +def test_no_results(version): + product_team = _create_org() + table_name = read_terraform_output("dynamodb_table_name.value") + client = dynamodb_client() + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import cache as drd_cache + from api.searchDeviceReferenceData.index import handler as drd_handler + + drd_cache["DYNAMODB_CLIENT"] = client + + pt_repo = ProductTeamRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + pt_repo.write(entity=product_team) + product = _create_product( + product={"name": "product-a"}, product_team=product_team + ) + + p_repo = CpmProductRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + p_repo.write(entity=product) + product_state = product.state() + params = {"product_team_id": product_team.id, "product_id": product_state["id"]} + result = drd_handler( + event={ + "headers": {"version": 1}, + "pathParameters": params, + } + ) + expected_result = json.dumps({"results": []}) + expected = { + "statusCode": 200, + "body": expected_result, + "headers": { + "Content-Length": str(len(expected_result)), + "Content-Type": "application/json", + "Version": version, + }, + } + _response_assertions( + result=result, expected=expected, check_body=True, check_content_length=True + ) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "version,device_ref_data", + [ + ("1", {"name": "device-ref-data-name-a"}), + ("1", {"name": "device-ref-data-name-b"}), + ("1", {"name": "device-ref-data-name-c"}), + ("1", {"name": "device-ref-data-name-d"}), + ], +) +def test_index(version, device_ref_data): + table_name = read_terraform_output("dynamodb_table_name.value") + client = dynamodb_client() + product_team = _create_org() + pt_repo = ProductTeamRepository( + table_name=table_name, + dynamodb_client=client, + ) + pt_repo.write(entity=product_team) + cpmproduct = _create_product( + product={"name": "product-a"}, product_team=product_team + ) + p_repo = CpmProductRepository(table_name=table_name, dynamodb_client=client) + p_repo.write(entity=cpmproduct) + product_state = cpmproduct.state() + drd = _create_device_ref_data(device_ref_data=device_ref_data, product=cpmproduct) + + params = {"product_team_id": product_team.id, "product_id": product_state["id"]} + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import cache as drd_cache + from api.searchDeviceReferenceData.index import handler as drd_handler + + drd_cache["DYNAMODB_CLIENT"] = client + drd_repo = DeviceReferenceDataRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + drd_repo.write(entity=drd) + + result = drd_handler( + event={ + "headers": {"version": version}, + "pathParameters": params, + } + ) + + expected_result = json.dumps({"results": [drd.state()]}) + + expected = { + "statusCode": 200, + "body": expected_result, + "headers": { + "Content-Length": str(len(expected_result)), + "Content-Type": "application/json", + "Version": version, + }, + } + _response_assertions( + result=result, expected=expected, check_body=True, check_content_length=True + ) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "version", + [ + "1", + ], +) +def test_index_no_such_product_team(version): + params = {"product_team_id": "123456", "product_id": "P.123-321"} + table_name = read_terraform_output("dynamodb_table_name.value") + client = dynamodb_client() + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import cache as drd_cache + from api.searchDeviceReferenceData.index import handler as drd_handler + + drd_cache["DYNAMODB_CLIENT"] = client + + DeviceReferenceDataRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + result = drd_handler( + event={ + "headers": {"version": version}, + "pathParameters": params, + } + ) + + result_body = json_loads(result["body"]) + + assert result["statusCode"] == 404 + assert result_body == { + "errors": [ + { + "code": "RESOURCE_NOT_FOUND", + "message": "Could not find ProductTeam for key ('123456')", + } + ] + } + + +@pytest.mark.integration +@pytest.mark.parametrize( + "version", + [ + "1", + ], +) +def test_index_no_such_product(version): + table_name = read_terraform_output("dynamodb_table_name.value") + client = dynamodb_client() + + product_team = _create_org() + pt_repo = ProductTeamRepository( + table_name=table_name, + dynamodb_client=client, + ) + pt_repo.write(entity=product_team) + + params = {"product_team_id": product_team.id, "product_id": "P.123-321"} + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import cache as drd_cache + from api.searchDeviceReferenceData.index import handler as drd_handler + + drd_cache["DYNAMODB_CLIENT"] = client + + DeviceReferenceDataRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + result = drd_handler( + event={ + "headers": {"version": version}, + "pathParameters": params, + } + ) + + result_body = json_loads(result["body"]) + + assert result["statusCode"] == 404 + assert result_body == { + "errors": [ + { + "code": "RESOURCE_NOT_FOUND", + "message": f"Could not find CpmProduct for key ('{product_team.id}', 'P.123-321')", + } + ] + } + + +@pytest.mark.integration +@pytest.mark.parametrize( + "device_ref_data", + [ + [ + {"name": "device-ref-data-name-a"}, + {"name": "device-ref-data-name-b"}, + {"name": "device-ref-data-name-c"}, + {"name": "device-ref-data-name-d"}, + ], + ], +) +def test_index_multiple_returned(device_ref_data): + version = 1 + table_name = read_terraform_output("dynamodb_table_name.value") + client = dynamodb_client() + + product_team = _create_org() + pt_repo = ProductTeamRepository( + table_name=table_name, + dynamodb_client=client, + ) + pt_repo.write(entity=product_team) + cpmproduct = _create_product( + product={"name": "product-a"}, product_team=product_team + ) + p_repo = CpmProductRepository(table_name=table_name, dynamodb_client=client) + p_repo.write(entity=cpmproduct) + product_state = cpmproduct.state() + params = {"product_team_id": product_team.id, "product_id": product_state["id"]} + drds = [] + for dev_ref_dat in device_ref_data: + drd = _create_device_ref_data(device_ref_data=dev_ref_dat, product=cpmproduct) + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import cache as drd_cache + + drd_cache["DYNAMODB_CLIENT"] = client + drd_repo = DeviceReferenceDataRepository( + table_name=drd_cache["DYNAMODB_TABLE"], + dynamodb_client=drd_cache["DYNAMODB_CLIENT"], + ) + + drd_repo.write(entity=drd) + drds.append(drd) + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchDeviceReferenceData.index import handler as drd_handler + + result = drd_handler( + event={ + "headers": {"version": version}, + "pathParameters": params, + } + ) + + assert result["statusCode"] == 200 + result_body = json_loads(result["body"]) + assert "results" in result_body + assert isinstance(result_body["results"], list) + validate_product_result_body(result_body["results"], [d.state() for d in drds]) diff --git a/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.failure.feature b/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.failure.feature new file mode 100644 index 000000000..50d4a8136 --- /dev/null +++ b/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.failure.feature @@ -0,0 +1,206 @@ +Feature: Create MHS Device - failure scenarios + These scenarios demonstrate failures to create a new MHS Device + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Cannot create a MHS Device with a Device body that is missing fields (no questionnaire_responses) and has extra param + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | bad_field | Not required | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | MISSING_VALUE | + | errors.0.message | CreateMhsDeviceIncomingParams.questionnaire_responses: field required | + | errors.1.code | VALIDATION_ERROR | + | errors.1.message | CreateMhsDeviceIncomingParams.bad_field: extra fields not permitted | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 235 | + + Scenario: Cannot create a MHS Device with a corrupt Device body + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + """ + {"invalid_array": [} + """ + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Invalid JSON body was provided: line 1 column 20 (char 19) | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 115 | + + Scenario: Cannot create a MHS Device with a Product Team that does not exist + When I make a "POST" request with "default" headers to "ProductTeam/not-a-product-team/Product/not-a-product/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses | {} | + Then I receive a status code "404" with body + | path | value | + | errors.0.code | RESOURCE_NOT_FOUND | + | errors.0.message | Could not find ProductTeam for key ('not-a-product-team') | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 116 | + + Scenario: Cannot create a MHS Device with a Product that does not exist + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/not-a-product/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses | {} | + Then I receive a status code "404" with body + | path | value | + | errors.0.code | RESOURCE_NOT_FOUND | + | errors.0.message | Could not find CpmProduct for key ('${ note(product_team_id) }', 'not-a-product') | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 156 | + + Scenario: Cannot create a MHS Device with a Product that does not have a party key + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses | {} | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Not an EPR Product: Cannot create MHS device for product without exactly one Party Key | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 143 | + + Scenario: Cannot create a MHS Device with a Device body that has no questionnaire responses for 'spine_mhs' + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses.not_spine_mhs.0.Question | Answer | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Require a 'spine_mhs' questionnaire response to create a MHS Device | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 124 | + + Scenario: Cannot create a MHS Device with a Device body that has multiple questionnaire responses for 'spine_mhs' + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses.spine_mhs.0.Address | http://example.com | + | questionnaire_responses.spine_mhs.0.Unique Identifier | 123456 | + | questionnaire_responses.spine_mhs.0.Managing Organization | Example Org | + | questionnaire_responses.spine_mhs.0.MHS Party key | party-key-001 | + | questionnaire_responses.spine_mhs.0.MHS CPA ID | cpa-id-001 | + | questionnaire_responses.spine_mhs.0.Approver URP | approver-123 | + | questionnaire_responses.spine_mhs.0.Contract Property Template Key | contract-key-001 | + | questionnaire_responses.spine_mhs.0.Date Approved | 2024-01-01 | + | questionnaire_responses.spine_mhs.0.Date DNS Approved | 2024-01-02 | + | questionnaire_responses.spine_mhs.0.Date Requested | 2024-01-03 | + | questionnaire_responses.spine_mhs.0.DNS Approver | dns-approver-456 | + | questionnaire_responses.spine_mhs.0.Interaction Type | FHIR | + | questionnaire_responses.spine_mhs.0.MHS FQDN | mhs.example.com | + | questionnaire_responses.spine_mhs.0.MHS Is Authenticated | PERSISTENT | + | questionnaire_responses.spine_mhs.0.Product Key | product-key-001 | + | questionnaire_responses.spine_mhs.0.Requestor URP | requestor-789 | + | questionnaire_responses.spine_mhs.1.Address | http://example.com | + | questionnaire_responses.spine_mhs.1.Unique Identifier | 123456 | + | questionnaire_responses.spine_mhs.1.Managing Organization | Example Org | + | questionnaire_responses.spine_mhs.1.MHS Party key | party-key-001 | + | questionnaire_responses.spine_mhs.1.MHS CPA ID | cpa-id-001 | + | questionnaire_responses.spine_mhs.1.Approver URP | approver-123 | + | questionnaire_responses.spine_mhs.1.Contract Property Template Key | contract-key-001 | + | questionnaire_responses.spine_mhs.1.Date Approved | 2024-01-01 | + | questionnaire_responses.spine_mhs.1.Date DNS Approved | 2024-01-02 | + | questionnaire_responses.spine_mhs.1.Date Requested | 2024-01-03 | + | questionnaire_responses.spine_mhs.1.DNS Approver | dns-approver-456 | + | questionnaire_responses.spine_mhs.1.Interaction Type | FHIR | + | questionnaire_responses.spine_mhs.1.MHS FQDN | mhs.example.com | + | questionnaire_responses.spine_mhs.1.MHS Is Authenticated | PERSISTENT | + | questionnaire_responses.spine_mhs.1.Product Key | product-key-001 | + | questionnaire_responses.spine_mhs.1.Requestor URP | requestor-789 | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Expected only one response for the 'spine_mhs' questionnaire | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 117 | + + Scenario: Cannot create a MHS Device with a Device body that has an invalid questionnaire responses for the questionnaire 'spine_mhs' + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses.spine_mhs.0.Address | http://example.com | + | questionnaire_responses.spine_mhs.0.Unique Identifier | 123456 | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | MISSING_VALUE | + | errors.0.message | Failed to validate data against 'spine_mhs/1': 'Managing Organization' is a required property | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 147 | diff --git a/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.success.feature b/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.success.feature new file mode 100644 index 000000000..d7df743c0 --- /dev/null +++ b/src/api/tests/feature_tests/features/createDeviceMessageHandlingSystem.success.feature @@ -0,0 +1,77 @@ +Feature: Create MHS Device - success scenarios + These scenarios demonstrate successful MHS Device creation + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Successfully create a MHS Device + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + And I note the response field "$.keys.0.key_type" as "party_key_tag" + And I note the response field "$.keys.0.key_value" as "party_key_value" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/MessageHandlingSystem" with body: + | path | value | + | questionnaire_responses.spine_mhs.0.Address | http://example.com | + | questionnaire_responses.spine_mhs.0.Unique Identifier | 123456 | + | questionnaire_responses.spine_mhs.0.Managing Organization | Example Org | + | questionnaire_responses.spine_mhs.0.MHS Party key | party-key-001 | + | questionnaire_responses.spine_mhs.0.MHS CPA ID | cpa-id-001 | + | questionnaire_responses.spine_mhs.0.Approver URP | approver-123 | + | questionnaire_responses.spine_mhs.0.Contract Property Template Key | contract-key-001 | + | questionnaire_responses.spine_mhs.0.Date Approved | 2024-01-01 | + | questionnaire_responses.spine_mhs.0.Date DNS Approved | 2024-01-02 | + | questionnaire_responses.spine_mhs.0.Date Requested | 2024-01-03 | + | questionnaire_responses.spine_mhs.0.DNS Approver | dns-approver-456 | + | questionnaire_responses.spine_mhs.0.Interaction Type | FHIR | + | questionnaire_responses.spine_mhs.0.MHS FQDN | mhs.example.com | + | questionnaire_responses.spine_mhs.0.MHS Is Authenticated | PERSISTENT | + | questionnaire_responses.spine_mhs.0.Product Key | product-key-001 | + | questionnaire_responses.spine_mhs.0.Requestor URP | requestor-789 | + Then I receive a status code "201" with body + | path | value | + | id | << ignore >> | + | name | Product-MHS | + | status | active | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + | keys | [] | + | questionnaire_responses | << ignore >> | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 1104 | + And I note the response field "$.id" as "device_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/Device/${ note(device_id) }" + Then I receive a status code "200" with body + | path | value | + | id | ${ note(device_id) } | + | name | Product-MHS | + | status | active | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + | keys | [] | + | tags.0.0.0 | ${ note(party_key_tag) } | + | tags.0.0.1 | ${ note(party_key_value) } | + | questionnaire_responses | << ignore >> | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 1147 | diff --git a/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.failure.feature b/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.failure.feature new file mode 100644 index 000000000..d1a48fe54 --- /dev/null +++ b/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.failure.feature @@ -0,0 +1,139 @@ +Feature: Create "Additional Interactions" Device Reference Data - failure scenarios + These scenarios demonstrate unsuccessful "Additional Interactions" Device Reference Data creation + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Fail to create an "AS Additional Interactions" Device Reference Data, with incomplete questionnaire response + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" with body: + | path | value | + | questionnaire_responses.spine_as_additional_interactions.0 | {} | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | MISSING_VALUE | + | errors.0.message | Failed to validate data against 'spine_as_additional_interactions/1': 'Interaction ID' is a required property | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 163 | + + Scenario: Fail to create an "AS Additional Interactions" Device Reference Data, with invalid questionnaire response (bad value) + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" with body: + | path | value | + | questionnaire_responses.spine_as_additional_interactions.0.Interaction ID | [] | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Failed to validate data against 'spine_as_additional_interactions/1': [] is not of type 'string' | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 153 | + + Scenario: Fail to create an "AS Additional Interactions" Device Reference Data, with invalid questionnaire response (unknown field) + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" with body: + | path | value | + | questionnaire_responses.spine_as_additional_interactions.0.Interaction ID | urn:nhs:names:services:ers:READ_PRACTITIONER_ROLE_R4_V001 | + | questionnaire_responses.spine_as_additional_interactions.0.unknown_field | 123 | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Failed to validate data against 'spine_as_additional_interactions/1': Additional properties are not allowed ('unknown_field' was unexpected) | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 197 | + + Scenario: Fail to create an "AS Additional Interactions" Device Reference Data, with invalid questionnaire name + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" with body: + | path | value | + | questionnaire_responses.bad_questionnaire_name.0.some_value | 123 | + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | CreateDeviceReferenceAdditionalInteractionsDataParams.questionnaire_responses.__key__: unexpected value; permitted: 'spine_as_additional_interactions' | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 207 | + + Scenario: Fail to create a second "AS Additional Interactions" Device Reference Data in the same EPR Product + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + And I note the response field "$.keys.0.key_value" as "party_key" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Additional Interactions Device Reference Data already exists for this Product | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 134 | + + Scenario: Fail to create an "AS Additional Interactions" Device Reference Data in non-EPR product + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Not an EPR Product: Cannot create Additional Interactions in Product without exactly one Party Key | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 155 | diff --git a/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.success.feature b/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.success.feature new file mode 100644 index 000000000..4802fb525 --- /dev/null +++ b/src/api/tests/feature_tests/features/createDeviceReferenceDataAdditionalInteractions.success.feature @@ -0,0 +1,119 @@ +Feature: Create "Additional Interactions" Device Reference Data - success scenarios + These scenarios demonstrate successful "Additional Interactions" Device Reference Data creation + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Successfully create an "AS Additional Interactions" Device Reference Data, with no questionnaire responses + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + And I note the response field "$.keys.0.key_value" as "party_key" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" + Then I receive a status code "201" with body + | path | value | + | id | << ignore >> | + | name | F5H1R-850000 - AS Additional Interactions | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | questionnaire_responses | {} | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 333 | + And I note the response field "$.id" as "device_reference_data_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/${ note(device_reference_data_id) }" + Then I receive a status code "200" with body + | path | value | + | id | ${ note(device_reference_data_id) } | + | name | F5H1R-850000 - AS Additional Interactions | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | questionnaire_responses | {} | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 333 | + + Scenario: Successfully create an "AS Additional Interactions" Device Reference Data, with questionnaire responses + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/Epr" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + And I note the response field "$.keys.0.key_value" as "party_key" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/AccreditedSystemsAdditionalInteractions" with body: + | path | value | + | questionnaire_responses.spine_as_additional_interactions.0.Interaction ID | urn:nhs:names:services:ers:READ_PRACTITIONER_ROLE_R4_V001 | + | questionnaire_responses.spine_as_additional_interactions.1.Interaction ID | urn:nhs:names:services:ebs:PRSC_IN080000UK07 | + Then I receive a status code "201" with body + | path | value | + | id | << ignore >> | + | name | F5H1R-850000 - AS Additional Interactions | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.id | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.questionnaire_name | spine_as_additional_interactions | + | questionnaire_responses.spine_as_additional_interactions/1.0.questionnaire_version | 1 | + | questionnaire_responses.spine_as_additional_interactions/1.0.created_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.data.Interaction ID | urn:nhs:names:services:ers:READ_PRACTITIONER_ROLE_R4_V001 | + | questionnaire_responses.spine_as_additional_interactions/1.1.id | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.1.questionnaire_name | spine_as_additional_interactions | + | questionnaire_responses.spine_as_additional_interactions/1.1.questionnaire_version | 1 | + | questionnaire_responses.spine_as_additional_interactions/1.1.created_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.1.data.Interaction ID | urn:nhs:names:services:ebs:PRSC_IN080000UK07 | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 938 | + And I note the response field "$.id" as "device_reference_data_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/${ note(device_reference_data_id) }" + Then I receive a status code "200" with body + | path | value | + | id | ${ note(device_reference_data_id) } | + | name | F5H1R-850000 - AS Additional Interactions | + | product_id | ${ note(product_id) } | + | product_team_id | ${ note(product_team_id) } | + | ods_code | F5H1R | + | created_on | << ignore >> | + | updated_on | << ignore >> | + | deleted_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.id | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.questionnaire_name | spine_as_additional_interactions | + | questionnaire_responses.spine_as_additional_interactions/1.0.questionnaire_version | 1 | + | questionnaire_responses.spine_as_additional_interactions/1.0.created_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.0.data.Interaction ID | urn:nhs:names:services:ers:READ_PRACTITIONER_ROLE_R4_V001 | + | questionnaire_responses.spine_as_additional_interactions/1.1.id | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.1.questionnaire_name | spine_as_additional_interactions | + | questionnaire_responses.spine_as_additional_interactions/1.1.questionnaire_version | 1 | + | questionnaire_responses.spine_as_additional_interactions/1.1.created_on | << ignore >> | + | questionnaire_responses.spine_as_additional_interactions/1.1.data.Interaction ID | urn:nhs:names:services:ebs:PRSC_IN080000UK07 | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 938 | diff --git a/src/api/tests/feature_tests/features/createDeviceReferenceDataMessageSet.failure.feature b/src/api/tests/feature_tests/features/createDeviceReferenceDataMessageSet.failure.feature index ace4239de..b2599be3b 100644 --- a/src/api/tests/feature_tests/features/createDeviceReferenceDataMessageSet.failure.feature +++ b/src/api/tests/feature_tests/features/createDeviceReferenceDataMessageSet.failure.feature @@ -135,10 +135,10 @@ Feature: Create "Message Set" Device Reference Data - failure scenarios And I note the response field "$.id" as "product_id" When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData/MhsMessageSet" Then I receive a status code "400" with body - | path | value | - | errors.0.code | VALIDATION_ERROR | - | errors.0.message | Cannot create Message Set in Product without exactly one Party Key | + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | Not an EPR Product: Cannot create MHS device for product without exactly one Party Key | And the response headers contain: | name | value | | Content-Type | application/json | - | Content-Length | 123 | + | Content-Length | 143 | diff --git a/src/api/tests/feature_tests/features/searchDeviceReferenceData.failure.feature b/src/api/tests/feature_tests/features/searchDeviceReferenceData.failure.feature new file mode 100644 index 000000000..8bbf87b7e --- /dev/null +++ b/src/api/tests/feature_tests/features/searchDeviceReferenceData.failure.feature @@ -0,0 +1,37 @@ +Feature: Search Device Reference Data - failures scenarios + These scenarios demonstrate unsuccessful Device Reference Data Search + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Search Device Reference Data per Product associated with a Product Team that does not exist + When I make a "GET" request with "default" headers to "ProductTeam/F5H1R.f9518c12-6c83-4544-97db-d9dd1d64da97/Product/P.XXX.YYY" + Then I receive a status code "404" with body + | path | value | + | errors.0.code | RESOURCE_NOT_FOUND | + | errors.0.message | Could not find ProductTeam for key ('F5H1R.f9518c12-6c83-4544-97db-d9dd1d64da97') | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 140 | + + Scenario: Search Device Reference Data per Product that does not exist associated with a Product Team + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + | keys.0.key_type | product_team_id_alias | + | keys.0.key_value | FOOBAR | + Given I note the response field "$.id" as "product_team_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/P.XXX.YYY" + Then I receive a status code "404" with body + | path | value | + | errors.0.code | RESOURCE_NOT_FOUND | + | errors.0.message | Could not find CpmProduct for key ('${ note(product_team_id) }', 'P.XXX.YYY') | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 152 | diff --git a/src/api/tests/feature_tests/features/searchDeviceReferenceData.success.feature b/src/api/tests/feature_tests/features/searchDeviceReferenceData.success.feature new file mode 100644 index 000000000..5a78092e7 --- /dev/null +++ b/src/api/tests/feature_tests/features/searchDeviceReferenceData.success.feature @@ -0,0 +1,119 @@ +Feature: Search Device Reference Data - success scenarios + These scenarios demonstrate successful Device Reference Data Search + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Successfully search Device Reference Data with no results + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + And I note the response field "$.id" as "product_team_id" + And I have already made a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" + Then I receive a status code "200" with body + | path | value | + | results | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 15 | + + Scenario: Successfully search one Device Reference Data + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + | keys.0.key_type | product_team_id_alias | + | keys.0.key_value | FOOBAR | + And I note the response field "$.id" as "product_team_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great CpmProduct | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" with body: + | path | value | + | name | My Device Reference Data | + And I note the response field "$.id" as "device_reference_data_id" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" + Then I receive a status code "200" with body + | path | value | + | results.0.id | ${ note(device_reference_data_id) } | + | results.0.product_id | ${ note(product_id) } | + | results.0.product_team_id | ${ note(product_team_id) } | + | results.0.name | My Device Reference Data | + | results.0.ods_code | F5H1R | + | results.0.created_on | << ignore >> | + | results.0.updated_on | << ignore >> | + | results.0.deleted_on | << ignore >> | + | results.0.questionnaire_responses | {} | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 331 | + + Scenario: Successfully search more than one Device Reference Data + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team | + | ods_code | F5H1R | + | keys.0.key_type | product_team_id_alias | + | keys.0.key_value | FOOBAR | + Given I note the response field "$.id" as "product_team_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product | + And I note the response field "$.id" as "product_id" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" with body: + | path | value | + | name | My Device Reference Data 1 | + And I note the response field "$.id" as "device_reference_data_id_1" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" with body: + | path | value | + | name | My Device Reference Data 2 | + And I note the response field "$.id" as "device_reference_data_id_2" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" with body: + | path | value | + | name | My Device Reference Data 3 | + And I note the response field "$.id" as "device_reference_data_id_3" + When I make a "GET" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id) }/DeviceReferenceData" + Then I receive a status code "200" with body where "results" has a length of "3" + | path | value | + | results.0.id | ${ note(device_reference_data_id_1) } | + | results.0.name | My Device Reference Data 1 | + | results.0.product_id | ${ note(product_id) } | + | results.0.product_team_id | ${ note(product_team_id) } | + | results.0.ods_code | F5H1R | + | results.0.created_on | << ignore >> | + | results.0.updated_on | << ignore >> | + | results.0.deleted_on | << ignore >> | + | results.0.questionnaire_responses | {} | + | results.1.id | ${ note(device_reference_data_id_2) } | + | results.1.name | My Device Reference Data 2 | + | results.1.product_id | ${ note(product_id) } | + | results.1.product_team_id | ${ note(product_team_id) } | + | results.1.ods_code | F5H1R | + | results.1.created_on | << ignore >> | + | results.1.updated_on | << ignore >> | + | results.1.deleted_on | << ignore >> | + | results.1.questionnaire_responses | {} | + | results.2.id | ${ note(device_reference_data_id_3) } | + | results.2.name | My Device Reference Data 3 | + | results.2.product_id | ${ note(product_id) } | + | results.2.product_team_id | ${ note(product_team_id) } | + | results.2.ods_code | F5H1R | + | results.2.created_on | << ignore >> | + | results.2.updated_on | << ignore >> | + | results.2.deleted_on | << ignore >> | + | results.2.questionnaire_responses | {} | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 973 | diff --git a/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py b/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py index 47f8638c1..1dcfe8f5f 100644 --- a/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py +++ b/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py @@ -24,7 +24,9 @@ def get_endpoint_lambda_mapping() -> ENDPOINT_LAMBDA_MAPPING: import api.createCpmProduct.index import api.createCpmProductForEpr.index import api.createDevice.index + import api.createDeviceMessageHandlingSystem.index import api.createDeviceReferenceData.index + import api.createDeviceReferenceDataASActions.index import api.createDeviceReferenceDataMessageSet.index import api.createProductTeam.index import api.deleteCpmProduct.index @@ -34,6 +36,7 @@ def get_endpoint_lambda_mapping() -> ENDPOINT_LAMBDA_MAPPING: import api.readProductTeam.index import api.readQuestionnaire.index import api.searchCpmProduct.index + import api.searchDeviceReferenceData.index import api.status.index return { @@ -42,13 +45,16 @@ def get_endpoint_lambda_mapping() -> ENDPOINT_LAMBDA_MAPPING: "ProductTeam/{product_team_id}/Product": api.createCpmProduct.index, "ProductTeam/{product_team_id}/Product/Epr": api.createCpmProductForEpr.index, "ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData": api.createDeviceReferenceData.index, + "ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData/AccreditedSystemsAdditionalInteractions": api.createDeviceReferenceDataASActions.index, "ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData/MhsMessageSet": api.createDeviceReferenceDataMessageSet.index, "ProductTeam/{product_team_id}/Product/{product_id}/Device": api.createDevice.index, + "ProductTeam/{product_team_id}/Product/{product_id}/Device/MessageHandlingSystem": api.createDeviceMessageHandlingSystem.index, }, "GET": { "ProductTeam/{product_team_id}": api.readProductTeam.index, "ProductTeam/{product_team_id}/Product": api.searchCpmProduct.index, "ProductTeam/{product_team_id}/Product/{product_id}": api.readCpmProduct.index, + "ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData": api.searchDeviceReferenceData.index, "ProductTeam/{product_team_id}/Product/{product_id}/DeviceReferenceData/{device_reference_data_id}": api.readDeviceReferenceData.index, "ProductTeam/{product_team_id}/Product/{product_id}/Device/{device_id}": api.readDevice.index, "Questionnaire/{questionnaire_id}": api.readQuestionnaire.index, diff --git a/src/api/tests/feature_tests/steps/steps.py b/src/api/tests/feature_tests/steps/steps.py index faf68f7f8..cae634b1f 100644 --- a/src/api/tests/feature_tests/steps/steps.py +++ b/src/api/tests/feature_tests/steps/steps.py @@ -278,6 +278,10 @@ def note_response_field(context: Context, jsonpath: str, alias: str): response=context.response.json(), jsonpath=jsonpath ) + # Lowercase if alias is "party_key_value" as tags are always lowercase + if alias == "party_key_value" and isinstance(context.notes[alias], str): + context.notes[alias] = context.notes[alias].lower() + javascript_path = jsonpath_to_javascript_path(jsonpath) context.postman_scenario.item[-1].event.append( Event( diff --git a/src/api/tests/feature_tests/steps/tests/test_endpoint_lambda_mapping.py b/src/api/tests/feature_tests/steps/tests/test_endpoint_lambda_mapping.py index 004de3120..9fc3b10e3 100644 --- a/src/api/tests/feature_tests/steps/tests/test_endpoint_lambda_mapping.py +++ b/src/api/tests/feature_tests/steps/tests/test_endpoint_lambda_mapping.py @@ -198,6 +198,43 @@ def test_parse_path_read_device_reference_data(): ) +def test_parse_path_search_device_reference_data(): + with api_lambda_environment_variables(): + import api.searchDeviceReferenceData.index + + endpoint_lambda_mapping = get_endpoint_lambda_mapping() + + assert parse_api_path( + method="GET", + path="ProductTeam/123/Product/456/DeviceReferenceData", + endpoint_lambda_mapping=endpoint_lambda_mapping, + ) == ( + { + "product_team_id": "123", + "product_id": "456", + }, + {}, + api.searchDeviceReferenceData.index, + ) + + +def test_parse_path_create_mhs_device(): + with api_lambda_environment_variables(): + import api.createDeviceMessageHandlingSystem.index + + endpoint_lambda_mapping = get_endpoint_lambda_mapping() + + assert parse_api_path( + method="POST", + path="ProductTeam/123/Product/456/Device/MessageHandlingSystem", # pragma: allowlist secret + endpoint_lambda_mapping=endpoint_lambda_mapping, + ) == ( + {"product_team_id": "123", "product_id": "456"}, + {}, + api.createDeviceMessageHandlingSystem.index, + ) + + def test_parse_path_error(): with pytest.raises(EndpointConfigurationError): parse_api_path(method="GET", path="ProductTeam/123", endpoint_lambda_mapping={}) diff --git a/src/api/tests/smoke_tests/test_smoke.py b/src/api/tests/smoke_tests/test_smoke.py index 5ebf78559..ee1c54865 100644 --- a/src/api/tests/smoke_tests/test_smoke.py +++ b/src/api/tests/smoke_tests/test_smoke.py @@ -85,7 +85,26 @@ def _request(base_url: str, headers: dict, path: str, method: str): "CreateDeviceReferenceDataIncomingParams.foo: extra fields not permitted", ], ], - # ('/ProductTeam/123/Product/abc/Device', 'POST', 400, ['MISSING_VALUE', 'VALIDATION_ERROR']), + [ + "/ProductTeam/123/Product/abc/Device", + "POST", + 400, + ["MISSING_VALUE", "VALIDATION_ERROR"], + [ + "CreateDeviceIncomingParams.name: field required", + "CreateDeviceIncomingParams.foo: extra fields not permitted", + ], + ], + [ + "/ProductTeam/123/Product/abc/Device/MessageHandlingSystem", + "POST", + 400, + ["MISSING_VALUE", "VALIDATION_ERROR"], + [ + "CreateMhsDeviceIncomingParams.questionnaire_responses: field required", + "CreateMhsDeviceIncomingParams.foo: extra fields not permitted", + ], + ], [ "/ProductTeam/123", "GET", @@ -107,6 +126,13 @@ def _request(base_url: str, headers: dict, path: str, method: str): ["RESOURCE_NOT_FOUND"], ["Could not find ProductTeam for key ('123')"], ], + [ + "/ProductTeam/123/Product/abc/DeviceReferenceData", + "GET", + 404, + ["RESOURCE_NOT_FOUND"], + ["Could not find ProductTeam for key ('123')"], + ], [ "/ProductTeam/123/Product/abc/DeviceReferenceData/xyz", "GET", @@ -114,7 +140,12 @@ def _request(base_url: str, headers: dict, path: str, method: str): ["RESOURCE_NOT_FOUND"], ["Could not find ProductTeam for key ('123')"], ], - # ['/ProductTeam/123/Product/abc/Device/xyz', 404, ['RESOURCE_NOT_FOUND'], ["Could not find ProductTeam for key ('123')"]], + [ + "/ProductTeam/123/Product/abc/Device/xyz", + 404, + ["RESOURCE_NOT_FOUND"], + ["Could not find ProductTeam for key ('123')"], + ], [ "/Questionnaire/987", "GET", diff --git a/src/layers/domain/api/common_steps/create_device.py b/src/layers/domain/api/common_steps/create_device.py new file mode 100644 index 000000000..784a55809 --- /dev/null +++ b/src/layers/domain/api/common_steps/create_device.py @@ -0,0 +1,50 @@ +from http import HTTPStatus + +from domain.api.common_steps.general import parse_event_body +from domain.api.common_steps.read_product import ( + parse_path_params, + read_product, + read_product_team, +) +from domain.core.cpm_product.v1 import CpmProduct +from domain.core.device.v3 import Device +from domain.repository.device_repository.v3 import DeviceRepository +from domain.request_models.v1 import CreateDeviceIncomingParams +from domain.response.validation_errors import mark_validation_errors_as_inbound + + +@mark_validation_errors_as_inbound +def parse_device_payload(data, cache) -> Device: + payload: dict = data[parse_event_body] + return CreateDeviceIncomingParams(**payload) + + +def create_device(data, cache) -> Device: + product: CpmProduct = data[read_product] + payload: CreateDeviceIncomingParams = data[parse_device_payload] + return product.create_device(**payload.dict()) + + +def write_device(data: dict[str, CpmProduct], cache) -> CpmProduct: + device: Device = data[create_device] + repo = DeviceRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + return repo.write(device) + + +def set_http_status(data, cache) -> tuple[HTTPStatus, str]: + device: Device = data[create_device] + return HTTPStatus.CREATED, device.state() + + +steps = [ + parse_event_body, + parse_path_params, + parse_device_payload, + read_product_team, + read_product, + create_device, + write_device, + set_http_status, +] diff --git a/src/layers/domain/api/common_steps/create_product.py b/src/layers/domain/api/common_steps/create_product.py index 91ddd64dc..77f17c4bc 100644 --- a/src/layers/domain/api/common_steps/create_product.py +++ b/src/layers/domain/api/common_steps/create_product.py @@ -48,14 +48,6 @@ def create_cpm_product( return product -def write_cpm_product(data: dict[str, CpmProduct], cache) -> CpmProduct: - product: CpmProduct = data[create_cpm_product] - product_repo = CpmProductRepository( - table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] - ) - return product_repo.write(product) - - def write_cpm_product( data: dict[str, CpmProduct], cache ) -> list["TransactWriteItemsOutputTypeDef"]: diff --git a/src/layers/domain/api/common_steps/read_product.py b/src/layers/domain/api/common_steps/read_product.py index e4d20146c..1858f48b9 100644 --- a/src/layers/domain/api/common_steps/read_product.py +++ b/src/layers/domain/api/common_steps/read_product.py @@ -2,6 +2,8 @@ from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent from domain.core.cpm_product.v1 import CpmProduct +from domain.core.error import NotEprProductError +from domain.core.product_key.v1 import ProductKeyType from domain.core.product_team.v3 import ProductTeam from domain.repository.cpm_product_repository.v3 import CpmProductRepository from domain.repository.product_team_repository.v2 import ProductTeamRepository @@ -35,6 +37,22 @@ def read_product(data, cache) -> CpmProduct: return cpm_product +def get_party_key(data, cache) -> str: + product: CpmProduct = data[read_product] + party_keys = ( + key.key_value + for key in product.keys + if key.key_type is ProductKeyType.PARTY_KEY + ) + try: + (party_key,) = party_keys + except ValueError: + raise NotEprProductError( + "Not an EPR Product: Cannot create MHS device for product without exactly one Party Key" + ) + return party_key + + def product_to_dict(data, cache) -> tuple[str, dict]: product: CpmProduct = data[read_product] return HTTPStatus.OK, product.state() @@ -45,6 +63,7 @@ def product_to_dict(data, cache) -> tuple[str, dict]: read_product_team, read_product, ] +epr_product_specific_steps = [get_party_key] after_steps = [ product_to_dict, ] diff --git a/src/layers/domain/core/device/tests/test_device_v3.py b/src/layers/domain/core/device/tests/test_device_v3.py index 21631e4f0..244ecdd00 100644 --- a/src/layers/domain/core/device/tests/test_device_v3.py +++ b/src/layers/domain/core/device/tests/test_device_v3.py @@ -1,3 +1,4 @@ +import json from datetime import datetime import pytest @@ -12,14 +13,13 @@ DeviceTagsClearedEvent, DeviceUpdatedEvent, DuplicateQuestionnaireResponse, - QuestionnaireNotFoundError, - QuestionnaireResponseNotFoundError, QuestionnaireResponseUpdatedEvent, ) from domain.core.device_key.v2 import DeviceKey, DeviceKeyType from domain.core.enum import Status from domain.core.error import DuplicateError, NotFoundError -from domain.core.questionnaire.v2 import Questionnaire, QuestionnaireResponse +from domain.core.questionnaire.tests.test_questionnaire_v3 import VALID_SCHEMA +from domain.core.questionnaire.v3 import Questionnaire, QuestionnaireResponse @pytest.fixture @@ -33,24 +33,24 @@ def device_v3(): @pytest.fixture -def questionnaire_response() -> QuestionnaireResponse: - questionnaire = Questionnaire(name="foo", version=2) - questionnaire.add_question(name="question1") - return questionnaire.respond(responses=[{"question1": ["hi"]}]) +def questionnaire() -> Questionnaire: + return Questionnaire( + name="my-questionnaire", version="1", json_schema=json.dumps(VALID_SCHEMA) + ) @pytest.fixture -def another_good_questionnaire_response() -> QuestionnaireResponse: - questionnaire = Questionnaire(name="foo", version=2) - questionnaire.add_question(name="question1") - return questionnaire.respond(responses=[{"question1": ["bye"]}]) +def questionnaire_response(questionnaire: Questionnaire) -> QuestionnaireResponse: + questionnaire_response = questionnaire.validate({"size": 4, "colour": "white"}) + return questionnaire_response @pytest.fixture -def another_questionnaire_response() -> QuestionnaireResponse: - questionnaire = Questionnaire(name="bar", version=2) - questionnaire.add_question(name="question1") - return questionnaire.respond(responses=[{"question1": ["bye"]}]) +def another_good_questionnaire_response( + questionnaire: Questionnaire, +) -> QuestionnaireResponse: + questionnaire_response = questionnaire.validate({"size": 7, "colour": "black"}) + return questionnaire_response def test_device_created_with_datetime(device_v3: Device): @@ -117,10 +117,10 @@ def test_device_add_questionnaire_response( event = device_v3.add_questionnaire_response( questionnaire_response=questionnaire_response ) - created_on_1 = questionnaire_response.created_on.isoformat() original_updated_on = device_v3.updated_on + assert device_v3.questionnaire_responses == { - "foo/2": {created_on_1: questionnaire_response} + "my-questionnaire/1": [questionnaire_response] } assert isinstance(event, QuestionnaireResponseUpdatedEvent) assert event.updated_on is not None @@ -129,12 +129,12 @@ def test_device_add_questionnaire_response( event_2 = device_v3.add_questionnaire_response( questionnaire_response=another_good_questionnaire_response ) - created_on_2 = another_good_questionnaire_response.created_on.isoformat() + assert device_v3.questionnaire_responses == { - "foo/2": { - created_on_1: questionnaire_response, - created_on_2: another_good_questionnaire_response, - } + "my-questionnaire/1": [ + questionnaire_response, + another_good_questionnaire_response, + ] } assert device_v3.updated_on == event_2.updated_on @@ -156,47 +156,6 @@ def test_device_cannot_add_same_questionnaire_response_twice( ) -def test_device_update_questionnaire_response( - device_v3: Device, - questionnaire_response: QuestionnaireResponse, - another_good_questionnaire_response: QuestionnaireResponse, -): - created_on = questionnaire_response.created_on - another_good_questionnaire_response.created_on = created_on - - device_v3.add_questionnaire_response(questionnaire_response=questionnaire_response) - event = device_v3.update_questionnaire_response( - questionnaire_response=another_good_questionnaire_response - ) - assert device_v3.questionnaire_responses == { - "foo/2": {created_on.isoformat(): another_good_questionnaire_response} - } - assert isinstance(event, QuestionnaireResponseUpdatedEvent) - assert event.updated_on is not None - assert event.updated_on == device_v3.updated_on - - -def test_device_update_questionnaire_response_mismatching_created_on_error( - device_v3: Device, - questionnaire_response: QuestionnaireResponse, - another_good_questionnaire_response: QuestionnaireResponse, -): - device_v3.add_questionnaire_response(questionnaire_response=questionnaire_response) - with pytest.raises(QuestionnaireResponseNotFoundError): - device_v3.update_questionnaire_response( - questionnaire_response=another_good_questionnaire_response - ) - - -def test_device_update_questionnaire_response_key_error( - device_v3: Device, questionnaire_response: QuestionnaireResponse -): - with pytest.raises(QuestionnaireNotFoundError): - device_v3.update_questionnaire_response( - questionnaire_response=questionnaire_response - ) - - def test_device_add_tag(device_v3: Device): event_1 = device_v3.add_tag(foo="first", bar="second") assert isinstance(event_1, DeviceTagAddedEvent) diff --git a/src/layers/domain/core/device/v3.py b/src/layers/domain/core/device/v3.py index 41e425945..755965f2a 100644 --- a/src/layers/domain/core/device/v3.py +++ b/src/layers/domain/core/device/v3.py @@ -5,6 +5,7 @@ from urllib.parse import urlencode from uuid import UUID, uuid4 +import orjson from attr import dataclass from domain.core.aggregate_root import UPDATED_ON, AggregateRoot, event from domain.core.base import BaseModel @@ -13,10 +14,7 @@ from domain.core.enum import Status from domain.core.error import DuplicateError, NotFoundError from domain.core.event import Event, EventDeserializer -from domain.core.questionnaire.v2 import ( - QuestionnaireResponse, - QuestionnaireResponseUpdatedEvent, -) +from domain.core.questionnaire.v3 import QuestionnaireResponse from domain.core.timestamp import now from domain.core.validation import DEVICE_NAME_REGEX from pydantic import Field, root_validator @@ -25,22 +23,10 @@ DEVICE_UPDATED_ON = f"device_{UPDATED_ON}" -class QuestionnaireNotFoundError(Exception): - pass - - -class QuestionnaireResponseNotFoundError(Exception): - pass - - class DuplicateQuestionnaireResponse(Exception): pass -class QuestionNotFoundError(Exception): - pass - - @dataclass(kw_only=True, slots=True) class DeviceCreatedEvent(Event): id: str @@ -222,6 +208,17 @@ def __eq__(self, other: "DeviceTag"): return self.hash == other.hash +@dataclass(kw_only=True, slots=True) +class QuestionnaireResponseUpdatedEvent(Event): + """ + This is adding the inital questionnaire response from the event body request. + """ + + id: str + questionnaire_responses: dict[str, list[QuestionnaireResponse]] + updated_on: str = None + + class Device(AggregateRoot): """ An entity in the database. It could model all sorts of different logical or @@ -246,10 +243,20 @@ class Device(AggregateRoot): deleted_on: datetime = Field(default=None) keys: list[DeviceKey] = Field(default_factory=list) tags: set[DeviceTag] | list[DeviceTag] = Field(default_factory=set) - questionnaire_responses: dict[str, dict[str, QuestionnaireResponse]] = Field( - default_factory=lambda: defaultdict(dict) + questionnaire_responses: dict[str, list[QuestionnaireResponse]] = Field( + default_factory=lambda: defaultdict(list) ) + def state_exclude_tags(self) -> dict: + """ + Returns a deepcopy, useful for bulk operations rather than dealing with events. + + Exclude tags as we shouldn't return tags to the user on create. + """ + device_dict = orjson.loads(self.json()) + device_dict.pop("tags", None) + return device_dict + @event def update(self, **kwargs) -> DeviceUpdatedEvent: kwargs[UPDATED_ON] = now() @@ -336,57 +343,23 @@ def clear_tags(self): def add_questionnaire_response( self, questionnaire_response: QuestionnaireResponse ) -> QuestionnaireResponseUpdatedEvent: - questionnaire_id = questionnaire_response.questionnaire.id + questionnaire_id = questionnaire_response.questionnaire_id questionnaire_responses = self.questionnaire_responses[questionnaire_id] - created_on_str = questionnaire_response.created_on.isoformat() - if created_on_str in questionnaire_responses: + current_created_ons = {qr.created_on for qr in questionnaire_responses} + if questionnaire_response.created_on in current_created_ons: raise DuplicateQuestionnaireResponse( "This Device already contains a " - f"response created on {created_on_str}" + f"response created on {questionnaire_response.created_on.isoformat()}" f"for Questionnaire {questionnaire_id}" ) - questionnaire_responses[created_on_str] = questionnaire_response - - return QuestionnaireResponseUpdatedEvent( - entity_id=self.id, - entity_keys=[k.dict() for k in self.keys], - entity_tags=[t.dict() for t in self.tags], - questionnaire_responses={ - qid: {_created_on: qr.dict() for _created_on, qr in _qr.items()} - for qid, _qr in self.questionnaire_responses.items() - }, - ) - - @event - def update_questionnaire_response( - self, - questionnaire_response: QuestionnaireResponse, - ) -> QuestionnaireResponseUpdatedEvent: - questionnaire_id = questionnaire_response.questionnaire.id - questionnaire_responses = self.questionnaire_responses.get(questionnaire_id) - if questionnaire_responses is None: - raise QuestionnaireNotFoundError( - "This device does not contain a Questionnaire " - f"with id '{questionnaire_id}'" - ) from None - - created_on_str = questionnaire_response.created_on.isoformat() - if created_on_str not in questionnaire_responses: - raise QuestionnaireResponseNotFoundError( - "This device does not contain a Questionnaire with a " - f"response created on '{created_on_str}'" - ) from None - - questionnaire_responses[created_on_str] = questionnaire_response + questionnaire_responses.append(questionnaire_response) return QuestionnaireResponseUpdatedEvent( - entity_id=self.id, - entity_keys=[k.dict() for k in self.keys], - entity_tags=[t.dict() for t in self.tags], + id=self.id, questionnaire_responses={ - qid: {_created_on: qr.dict() for _created_on, qr in _qr.items()} - for qid, _qr in self.questionnaire_responses.items() + q_name: [qr.dict() for qr in qrs] + for q_name, qrs in self.questionnaire_responses.items() }, ) diff --git a/src/layers/domain/core/device_reference_data/v1.py b/src/layers/domain/core/device_reference_data/v1.py index 3c6ae5e85..238bf458a 100644 --- a/src/layers/domain/core/device_reference_data/v1.py +++ b/src/layers/domain/core/device_reference_data/v1.py @@ -5,7 +5,7 @@ from attr import dataclass from domain.core.aggregate_root import AggregateRoot, event from domain.core.cpm_system_id.v1 import ProductId -from domain.core.device.v2 import DuplicateQuestionnaireResponse +from domain.core.device.v3 import DuplicateQuestionnaireResponse from domain.core.event import Event from domain.core.questionnaire.v3 import QuestionnaireResponse from domain.core.timestamp import now @@ -27,6 +27,10 @@ class DeviceReferenceDataCreatedEvent(Event): @dataclass(kw_only=True, slots=True) class QuestionnaireResponseUpdatedEvent(Event): + """ + This is adding the inital questionnaire response from the event body request. + """ + id: str questionnaire_responses: dict[str, list[QuestionnaireResponse]] updated_on: str = None diff --git a/src/layers/domain/core/error.py b/src/layers/domain/core/error.py index f2fc1d4c1..922510e16 100644 --- a/src/layers/domain/core/error.py +++ b/src/layers/domain/core/error.py @@ -48,3 +48,11 @@ class EventExpected(Exception): class ConfigurationError(Exception): pass + + +class NotEprProductError(Exception): + pass + + +class InvalidSpineMhsResponse(Exception): + pass diff --git a/src/layers/domain/repository/device_repository/tests/v3/test_device_repository_questionnaire_responses_v3.py b/src/layers/domain/repository/device_repository/tests/v3/test_device_repository_questionnaire_responses_v3.py index 5dad90fca..54158b7fc 100644 --- a/src/layers/domain/repository/device_repository/tests/v3/test_device_repository_questionnaire_responses_v3.py +++ b/src/layers/domain/repository/device_repository/tests/v3/test_device_repository_questionnaire_responses_v3.py @@ -1,43 +1,65 @@ +import json + import pytest from domain.core.device.v3 import Device -from domain.core.questionnaire.v2 import Questionnaire +from domain.core.questionnaire.v3 import Questionnaire from domain.core.root.v3 import Root -from domain.repository.device_repository.tests.utils import devices_exactly_equal from domain.repository.device_repository.v3 import DeviceRepository +VALID_SHOE_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "shoe-size": { + "type": "number", + }, + "foot": { + "type": "string", + "enum": ["L", "R"], + }, + }, + "required": ["shoe-size", "foot"], + "additionalProperties": False, +} + +VALID_HEALTH_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "weight": { + "type": "number", + }, + "height": { + "type": "number", + }, + }, + "required": ["weight", "height"], + "additionalProperties": False, +} + @pytest.fixture -def shoe_questionnaire() -> Device: - questionnaire = Questionnaire(name="shoe", version=1) - questionnaire.add_question( - name="foot", answer_types=(str,), mandatory=True, choices={"L", "R"} +def shoe_questionnaire() -> Questionnaire: + return Questionnaire( + name="shoe", version="1", json_schema=json.dumps(VALID_SHOE_SCHEMA) ) - questionnaire.add_question(name="shoe-size", answer_types=(int,), mandatory=True) - return questionnaire @pytest.fixture -def health_questionnaire() -> Device: - questionnaire = Questionnaire(name="health", version=1) - questionnaire.add_question(name="weight", answer_types=(int,), mandatory=True) - questionnaire.add_question(name="height", answer_types=(int,), mandatory=True) - return questionnaire +def health_questionnaire() -> Questionnaire: + return Questionnaire( + name="health", version="1", json_schema=json.dumps(VALID_HEALTH_SCHEMA) + ) @pytest.fixture def device( shoe_questionnaire: Questionnaire, health_questionnaire: Questionnaire ) -> Device: - shoe_response_1 = shoe_questionnaire.respond( - responses=[{"foot": ["L"]}, {"shoe-size": [123]}], - ) - shoe_response_2 = shoe_questionnaire.respond( - responses=[{"foot": ["L"]}, {"shoe-size": [345]}], - ) + shoe_response_1 = shoe_questionnaire.validate({"foot": "L", "shoe-size": 123}) + shoe_response_2 = shoe_questionnaire.validate({"foot": "L", "shoe-size": 345}) - health_response = health_questionnaire.respond( - responses=[{"weight": [123]}, {"height": [345]}] - ) + health_response = health_questionnaire.validate({"weight": 123, "height": 345}) org = Root.create_ods_organisation(ods_code="AB123") product_team = org.create_product_team(name="Team") @@ -68,41 +90,3 @@ def test__device_repository__with_questionnaires_and_tags( device.add_tag(foo="bar") repository.write(device) assert repository.read(device.id) == device - - -@pytest.mark.integration -def test__device_repository__modify_questionnaire_response_that_has_been_persisted( - device: Device, repository: DeviceRepository, shoe_questionnaire: Questionnaire -): - # Persist model before updating model - repository.write(device) - intermediate_device = repository.read(device.id) - - # Update the model - questionnaire_responses = intermediate_device.questionnaire_responses - assert len(questionnaire_responses["shoe/1"]) == 2 - (_questionnaire_response, _) = questionnaire_responses["shoe/1"].values() - - questionnaire_response = shoe_questionnaire.respond( - responses=[{"foot": ["R"]}, {"shoe-size": [789]}] - ) - questionnaire_response.created_on = _questionnaire_response.created_on - - intermediate_device.update_questionnaire_response( - questionnaire_response=questionnaire_response - ) - - # Persist and verify consistency - repository.write(intermediate_device) - device_from_db = repository.read(intermediate_device.id) - assert devices_exactly_equal(device_from_db, intermediate_device) - assert not devices_exactly_equal(device_from_db, device) - assert device_from_db.questionnaire_responses["shoe/1"][ - _questionnaire_response.created_on.isoformat() - ].answers == [ - {"foot": ["R"]}, - {"shoe-size": [789]}, - ] - - assert device_from_db.created_on == device.created_on - assert device_from_db.updated_on > device.updated_on diff --git a/src/layers/domain/repository/device_repository/v3.py b/src/layers/domain/repository/device_repository/v3.py index cc9184ca8..f476e8afd 100644 --- a/src/layers/domain/repository/device_repository/v3.py +++ b/src/layers/domain/repository/device_repository/v3.py @@ -12,11 +12,11 @@ DeviceTagsAddedEvent, DeviceTagsClearedEvent, DeviceUpdatedEvent, + QuestionnaireResponseUpdatedEvent, ) from domain.core.device_key.v2 import DeviceKey from domain.core.enum import Status from domain.core.event import Event -from domain.core.questionnaire.v2 import QuestionnaireResponseUpdatedEvent from domain.repository.compression import pkl_dumps_gzip, pkl_loads_gzip from domain.repository.errors import ItemNotFound from domain.repository.keys.v3 import TableKey @@ -432,18 +432,12 @@ def handle_DeviceTagsClearedEvent(self, event: DeviceTagsClearedEvent): def handle_QuestionnaireResponseUpdatedEvent( self, event: QuestionnaireResponseUpdatedEvent - ): - keys = {DeviceKey(**key) for key in event.entity_keys} - tags = {DeviceTag(__root__=tag) for tag in event.entity_tags} - return update_device_indexes( - table_name=self.table_name, - id=event.entity_id, - keys=keys, - tags=tags, - data={ - "questionnaire_responses": event.questionnaire_responses, - "updated_on": event.updated_on, - }, + ) -> TransactItem: + pk = TableKey.DEVICE.key(event.id) + data = asdict(event) + data.pop("id") + return update_transactions( + table_name=self.table_name, primary_keys=[marshall(pk=pk, sk=pk)], data=data ) def handle_bulk(self, item: dict) -> list[dict]: diff --git a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/field_mapping.json b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/field_mapping.json index 1902a0d43..bd3a781a3 100644 --- a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/field_mapping.json +++ b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/field_mapping.json @@ -1,3 +1,3 @@ { - "nhs_as_svc_ia": "Interaction Id" + "nhs_as_svc_ia": "Interaction ID" } diff --git a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/v1.json b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/v1.json index 2a33767b7..632b26fcd 100644 --- a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/v1.json +++ b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/spine_as_additional_interactions/v1.json @@ -2,10 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { - "Interaction Id": { + "Interaction ID": { "type": "string" } }, - "required": ["Interaction Id"], + "required": ["Interaction ID"], "additionalProperties": false } diff --git a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/tests/test_spine_questionnaires.py b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/tests/test_spine_questionnaires.py index 728e0ef5e..70556b112 100644 --- a/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/tests/test_spine_questionnaires.py +++ b/src/layers/domain/repository/questionnaire_repository/v2/questionnaires/tests/test_spine_questionnaires.py @@ -42,9 +42,9 @@ def test_spine_as_questionnaires_pass(nhs_accredited_system: NhsAccreditedSystem assert response.questionnaire_version == "1" assert response.data == as_data - # Answer the additional_interactions questionnaire once per Interaction Id - for interaction_id in as_interactions_data["Interaction Id"]: - _as_interaction_data = {"Interaction Id": interaction_id} + # Answer the additional_interactions questionnaire once per Interaction ID + for interaction_id in as_interactions_data["Interaction ID"]: + _as_interaction_data = {"Interaction ID": interaction_id} response = as_interactions_questionnaire.validate(_as_interaction_data) assert ( response.questionnaire_name diff --git a/src/layers/domain/request_models/v1.py b/src/layers/domain/request_models/v1.py index a13f10c7f..0434134b0 100644 --- a/src/layers/domain/request_models/v1.py +++ b/src/layers/domain/request_models/v1.py @@ -43,6 +43,14 @@ class CreateDeviceReferenceMessageSetsDataParams(BaseModel, extra=Extra.forbid): ) +class CreateDeviceReferenceAdditionalInteractionsDataParams( + BaseModel, extra=Extra.forbid +): + questionnaire_responses: dict[ + Literal["spine_as_additional_interactions"], list[dict] + ] = Field(default_factory=lambda: defaultdict(list)) + + class DeviceReferenceDataPathParams(BaseModel, extra=Extra.forbid): product_id: str = Field(...) product_team_id: str = Field(...) @@ -59,6 +67,11 @@ class CreateDeviceIncomingParams(BaseModel, extra=Extra.forbid): name: str = Field(...) +class CreateMhsDeviceIncomingParams(BaseModel, extra=Extra.forbid): + name: str = "Product-MHS" + questionnaire_responses: dict[str, list[dict]] = Field(...) + + class DevicePathParams(BaseModel, extra=Extra.forbid): product_id: str = Field(...) product_team_id: str = Field(...) diff --git a/src/layers/domain/response/response_matrix.py b/src/layers/domain/response/response_matrix.py index 96cc53b2e..d8f3c59c5 100644 --- a/src/layers/domain/response/response_matrix.py +++ b/src/layers/domain/response/response_matrix.py @@ -1,7 +1,11 @@ from http import HTTPStatus from api_utils.versioning.errors import VersionException -from domain.core.error import ConfigurationError +from domain.core.error import ( + ConfigurationError, + InvalidSpineMhsResponse, + NotEprProductError, +) from domain.core.questionnaire.v3 import ( QuestionnaireResponseMissingValue, QuestionnaireResponseValidationError, @@ -46,6 +50,8 @@ InboundMissingValue: SpineCoding.MISSING_VALUE, InboundJSONDecodeError: SpineCoding.VALIDATION_ERROR, QuestionnaireResponseValidationError: SpineCoding.VALIDATION_ERROR, + NotEprProductError: SpineCoding.VALIDATION_ERROR, + InvalidSpineMhsResponse: SpineCoding.VALIDATION_ERROR, QuestionnaireResponseMissingValue: SpineCoding.MISSING_VALUE, InvalidOdsCodeError: SpineCoding.UNPROCESSABLE_ENTITY, ConfigurationError: SpineCoding.VALIDATION_ERROR,