diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d33209c4..c6256051d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2025-02-21 +- [PI-754] Search Product +- Dependabot: datamodel-code-generator + ## 2025-02-20 - [PI-790] Remove EPR from swagger diff --git a/VERSION b/VERSION index 11f64d88f..73130fe3a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2025.02.20 +2025.02.21 diff --git a/changelog/2025-02-21.md b/changelog/2025-02-21.md new file mode 100644 index 000000000..84fe4e29a --- /dev/null +++ b/changelog/2025-02-21.md @@ -0,0 +1,2 @@ +- [PI-754] Search Product +- Dependabot: datamodel-code-generator diff --git a/infrastructure/swagger/05_paths.yaml b/infrastructure/swagger/05_paths.yaml index 3579f5c8b..d0f7a069e 100644 --- a/infrastructure/swagger/05_paths.yaml +++ b/infrastructure/swagger/05_paths.yaml @@ -14,7 +14,8 @@ x-definitions: tags: - name: Core Product ID Endpoints description: Create, Read and Delete Product IDs - + - name: Options + description: These exist for CORS paths: /_status: get: @@ -44,6 +45,8 @@ paths: options: operationId: createproductteamcors summary: Create a Product Team resource (OPTIONS) + tags: + - Options responses: "400": $ref: "#/components/responses/BadRequest" @@ -161,6 +164,8 @@ paths: options: operationId: createproductcors summary: Create a Product resource (OPTIONS) + tags: + - Options parameters: - $ref: "#/components/parameters/ProductTeamId" responses: @@ -228,25 +233,69 @@ paths: security: - ${authoriser_name}: [] - app-level0: [] - # get: - # operationId: searchCpmProduct - # summary: Retrieve all Products associated with a Product Team (GET) - # parameters: - # - $ref: "#/components/parameters/ProductTeamId" - # - $ref: "#/components/parameters/HeaderVersion" - # - $ref: "#/components/parameters/HeaderRequestId" - # - $ref: "#/components/parameters/HeaderCorrelationId" - # responses: - # "200": - # $ref: "#/components/responses/ProductSearch" - # "404": - # $ref: "#/components/responses/NotFound" - # x-amazon-apigateway-integration: - # <<: *ApiGatewayIntegration - # uri: ${method_searchCpmProduct} - # security: - # - ${authoriser_name}: [] - # - app-level0: [] + + /Product: + options: + operationId: searchproductcors + summary: Search Products (OPTIONS) + tags: + - Options + responses: + "400": + $ref: "#/components/responses/BadRequest" + "200": + description: "200 response" + headers: + Access-Control-Allow-Origin: + schema: + type: "string" + Access-Control-Allow-Methods: + schema: + type: "string" + Access-Control-Allow-Headers: + schema: + type: "string" + content: + application/json: + schema: + $ref: "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + responseParameters: + method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'" + method.response.header.Access-Control-Allow-Headers: "'apikey,authorization,content-type,version'" + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: "never" + type: "mock" + security: + - ${authoriser_name}: [] + - app-level0: [] + get: + operationId: searchProduct + summary: Retrieve all Products associated with a Product Team or Organisation (GET) + tags: + - Core Product ID Endpoints + parameters: + - $ref: "#/components/parameters/ProductTeamIdQuery" + - $ref: "#/components/parameters/OrganisationCodeQuery" + - $ref: "#/components/parameters/HeaderVersion" + - $ref: "#/components/parameters/HeaderRequestId" + - $ref: "#/components/parameters/HeaderCorrelationId" + responses: + "200": + $ref: "#/components/responses/ProductSearch" + "400": + $ref: "#/components/responses/SearchProductBadRequest" + x-amazon-apigateway-integration: + <<: *ApiGatewayIntegration + uri: ${method_searchProduct} + security: + - ${authoriser_name}: [] + - app-level0: [] /ProductTeam/{product_team_id}/Product/{product_id}: get: diff --git a/infrastructure/swagger/07_components--schemas--domain.yaml b/infrastructure/swagger/07_components--schemas--domain.yaml index 3ae81914c..c36a85a2a 100644 --- a/infrastructure/swagger/07_components--schemas--domain.yaml +++ b/infrastructure/swagger/07_components--schemas--domain.yaml @@ -124,3 +124,78 @@ components: example: code: "RESOURCE_DELETED" message: "P.XYZ-123 has been deleted." + ProductSearchResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + org_code: + type: string + product_teams: + type: array + items: + type: object + properties: + product_team_id: + type: string + products: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + product_team_id: + type: string + ods_code: + type: string + status: + type: string + keys: + type: array + items: + type: object + properties: + key_type: + type: string + key_value: + type: string + created_on: + type: string + updated_on: + type: string + nullable: true + deleted_on: + type: string + nullable: true + example: + results: + - org_code: "xyzzy" + product_teams: + - product_team_id: "1234" + products: + - id: "P.123" + product_team_id: "1234" + name: "My Great Product 1" + ods_code: "F5H1R" + status: "active" + created_on: "2024-10-15T10:00:00Z" + updated_on: "null" + deleted_on: "null" + keys: [] + - product_team_id: "5678" + products: + - id: "P.xyz" + product_team_id: "5678" + name: "My Great Product 3" + ods_code: "F5H1R" + status: "active" + created_on: "2024-10-15T10:00:00Z" + updated_on: "null" + deleted_on: "null" + keys: [] diff --git a/infrastructure/swagger/12_components--responses.yaml b/infrastructure/swagger/12_components--responses.yaml index 53aee4039..ce84fdf14 100644 --- a/infrastructure/swagger/12_components--responses.yaml +++ b/infrastructure/swagger/12_components--responses.yaml @@ -69,6 +69,25 @@ components: message: "Duplicate 'Interaction ID' provided: value '' occurs times in the questionnaire response." - code: "VALIDATION_ERROR" message: "SubCpmProductPathParams.environment: value is not a valid enumeration member; permitted: 'dev', 'qa', 'ref', 'int', 'prod'" + SearchProductBadRequest: + description: searchProduct Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + ValidationError: + value: + errors: + - code: "VALIDATION_ERROR" + message: "SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: {'product_team_id', 'organisation_code'}." + - code: "VALIDATION_ERROR" + message: "SearchSDSDeviceQueryParams.foo: extra fields not permitted" + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: "*" UnprocessableContent: description: Unprocessable Content content: @@ -125,3 +144,9 @@ components: application/json: schema: $ref: "#/components/schemas/CPMProductDeleteResponse" + ProductSearch: + description: Search Product operation successful + content: + application/json: + schema: + $ref: "#/components/schemas/ProductSearchResponse" diff --git a/infrastructure/swagger/13_components--parameters--query.yaml b/infrastructure/swagger/13_components--parameters--query.yaml index ed97d539c..f84cc9d8a 100644 --- a/infrastructure/swagger/13_components--parameters--query.yaml +++ b/infrastructure/swagger/13_components--parameters--query.yaml @@ -1 +1,17 @@ --- +components: + parameters: + ProductTeamIdQuery: + name: product_team_id + in: query + required: false + description: The ID of the product team to filter results by. + schema: + type: string + OrganisationCodeQuery: + name: organisation_code + in: query + required: false + description: The organisation code to filter results by. + schema: + type: string diff --git a/infrastructure/terraform/per_workspace/main.tf b/infrastructure/terraform/per_workspace/main.tf index 0bdc3319a..ff38e04b7 100644 --- a/infrastructure/terraform/per_workspace/main.tf +++ b/infrastructure/terraform/per_workspace/main.tf @@ -129,7 +129,7 @@ module "lambdas" { } } environment_variables = { - DYNAMODB_TABLE = contains(["createProductTeam", "readProductTeam", "deleteProductTeam", "createCpmProduct", "readCpmProduct", "searchCpmProduct", "deleteCpmProduct"], each.key) ? module.cpmtable.dynamodb_table_name : module.eprtable.dynamodb_table_name + DYNAMODB_TABLE = contains(["createProductTeam", "readProductTeam", "deleteProductTeam", "createCpmProduct", "readCpmProduct", "searchProduct", "deleteCpmProduct"], each.key) ? module.cpmtable.dynamodb_table_name : module.eprtable.dynamodb_table_name } attach_policy_statements = length((fileset("${path.module}/../../../src/api/${each.key}/policies", "*.json"))) > 0 policy_statements = { diff --git a/pyproject.toml b/pyproject.toml index d6b64f938..c487b3853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "connecting-party-manager" -version = "2025.02.20" +version = "2025.02.21" description = "Repository for the Connecting Party Manager API and related services" authors = ["NHS England"] license = "LICENSE.md" @@ -41,7 +41,7 @@ hypothesis = "^6.87.3" aws-lambda-powertools = { extras = ["aws-sdk"], version = "^2.26.0" } parse = "^1.19.1" pytest-mock = "^3.12.0" -datamodel-code-generator = "^0.26.0" +datamodel-code-generator = "^0.28.1" pyyaml = "^6.0.1" proxygen-cli = "^2.1.14" moto = "^5.0.1" diff --git a/src/api/deleteProductTeam/src/v1/steps.py b/src/api/deleteProductTeam/src/v1/steps.py index 660ca5da6..609655ca8 100644 --- a/src/api/deleteProductTeam/src/v1/steps.py +++ b/src/api/deleteProductTeam/src/v1/steps.py @@ -1,6 +1,7 @@ from http import HTTPStatus from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent +from domain.core.enum import Status from domain.core.error import ConflictError from domain.core.product_team import ProductTeam from domain.repository.cpm_product_repository import CpmProductRepository @@ -29,7 +30,9 @@ def read_products(data, cache): product_repo = CpmProductRepository( table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] ) - cpm_products = product_repo.search(product_team_id=product_team.id) + cpm_products = product_repo.search_by_product_team( + product_team_id=product_team.id, status=Status.ACTIVE + ) if cpm_products: product_ids = [str(product.id) for product in cpm_products] diff --git a/src/api/searchProduct/index.py b/src/api/searchProduct/index.py new file mode 100644 index 000000000..142481a03 --- /dev/null +++ b/src/api/searchProduct/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/searchProduct/make/make.py b/src/api/searchProduct/make/make.py new file mode 100644 index 000000000..f33cd7068 --- /dev/null +++ b/src/api/searchProduct/make/make.py @@ -0,0 +1,4 @@ +from builder.lambda_build import build + +if __name__ == "__main__": + build(__file__) diff --git a/src/api/searchProduct/policies/dynamodb.json b/src/api/searchProduct/policies/dynamodb.json new file mode 100644 index 000000000..a1dd66409 --- /dev/null +++ b/src/api/searchProduct/policies/dynamodb.json @@ -0,0 +1 @@ +["dynamodb:Query"] diff --git a/src/api/searchProduct/policies/kms.json b/src/api/searchProduct/policies/kms.json new file mode 100644 index 000000000..f34357e28 --- /dev/null +++ b/src/api/searchProduct/policies/kms.json @@ -0,0 +1 @@ +["kms:Decrypt"] diff --git a/src/api/searchProduct/src/v1/steps.py b/src/api/searchProduct/src/v1/steps.py new file mode 100644 index 000000000..9f77cccfb --- /dev/null +++ b/src/api/searchProduct/src/v1/steps.py @@ -0,0 +1,66 @@ +from http import HTTPStatus + +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent +from domain.core.enum import Status +from domain.repository.cpm_product_repository import CpmProductRepository +from domain.repository.errors import ItemNotFound +from domain.repository.product_team_repository.v1 import ProductTeamRepository +from domain.request_models.v1 import SearchProductQueryParams +from domain.response.response_models import SearchProductResponse +from domain.response.validation_errors import mark_validation_errors_as_inbound +from event.step_chain import StepChain + + +@mark_validation_errors_as_inbound +def _parse_event_query(query_params: dict): + search_query_params = SearchProductQueryParams(**query_params) + return search_query_params.get_non_null_params() + + +def parse_event_query(data, cache): + event = APIGatewayProxyEvent(data[StepChain.INIT]) + query_params = _parse_event_query(query_params=event.query_string_parameters or {}) + return { + "query_params": query_params, + "host": event.multi_value_headers["Host"], + } + + +def query_products(data, cache) -> list: + event_data: dict = data[parse_event_query] + query_params: dict = event_data.get("query_params") + product_repo = CpmProductRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + + if "product_team_id" in query_params: + product_team_repo = ProductTeamRepository( + table_name=cache["DYNAMODB_TABLE"], dynamodb_client=cache["DYNAMODB_CLIENT"] + ) + # Allow product team id or product team alias + try: + product_team = product_team_repo.read(id=query_params["product_team_id"]) + except ItemNotFound: + return [] + product_team_id = product_team.id + return product_repo.search_by_product_team( + product_team_id, status=Status.ACTIVE + ) + elif "organisation_code" in query_params: + return product_repo.search_by_organisation( + query_params["organisation_code"], status=Status.ACTIVE + ) + + +def return_products(data, cache) -> tuple[HTTPStatus, str]: + cpm_products = data[query_products] + response = SearchProductResponse(cpm_products) + + return HTTPStatus.OK, response.state() + + +steps = [ + parse_event_query, + query_products, + return_products, +] diff --git a/src/api/searchProduct/tests/test_index.py b/src/api/searchProduct/tests/test_index.py new file mode 100644 index 000000000..2fb07d9e2 --- /dev/null +++ b/src/api/searchProduct/tests/test_index.py @@ -0,0 +1,565 @@ +import json +import os +from unittest import mock + +import pytest +from domain.core.root import Root +from domain.repository.cpm_product_repository import CpmProductRepository +from domain.repository.product_team_repository import ProductTeamRepository +from event.json import json_loads + +from conftest import dynamodb_client_with_sleep +from test_helpers.response_assertions import _response_assertions +from test_helpers.terraform import read_terraform_output +from test_helpers.validate_search_response import validate_product_result_body + +VERSION = "1" +ODS_CODE = "F5H1R" +PRODUCT_TEAM_NAME = "product-team-name" +PRODUCT_TEAM_ID = "sample_product_team_id" +PRODUCT_TEAM_KEYS = [{"key_type": "product_team_id_alias", "key_value": "FOOBAR"}] +PRODUCT_NAME = "product-name" +PRODUCT_ID = "P.AAA-367" + +ALLOWED_PARAMS = ("product_team_id", "organisation_code") + + +def _create_org(): + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team( + name=PRODUCT_TEAM_NAME, keys=PRODUCT_TEAM_KEYS + ) + return product_team + + +@pytest.mark.integration +def test_no_results(): + product_team = _create_org() + product_team_id = product_team.id + params = {"product_team_id": product_team_id} + + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler as product_handler + + product_cache["DYNAMODB_CLIENT"] = client + + pt_repo = ProductTeamRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + + pt_repo.write(entity=product_team) + + result = product_handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + 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 +def test_search_by_product_team_id(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team(name=PRODUCT_TEAM_NAME) + product_team_repo = ProductTeamRepository( + table_name=table_name, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + cpm_product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler + + product_cache["DYNAMODB_CLIENT"] = client + product_repo = CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + product_repo.write(cpm_product) + + params = {"product_team_id": str(product_team.id)} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + expected_result = json.dumps( + { + "results": [ + { + "org_code": ODS_CODE, + "product_teams": [ + { + "product_team_id": product_team.id, + "products": [cpm_product.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 +def test_search_by_product_team_alias(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team( + name=PRODUCT_TEAM_NAME, keys=PRODUCT_TEAM_KEYS + ) + product_team_repo = ProductTeamRepository( + table_name=table_name, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + cpm_product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler + + product_cache["DYNAMODB_CLIENT"] = client + product_repo = CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + product_repo.write(cpm_product) + + params = {"product_team_id": "FOOBAR"} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + expected_result = json.dumps( + { + "results": [ + { + "org_code": ODS_CODE, + "product_teams": [ + { + "product_team_id": product_team.id, + "products": [cpm_product.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 +def test_index_org_code(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team( + name=PRODUCT_TEAM_NAME, keys=PRODUCT_TEAM_KEYS + ) + product_team_repo = ProductTeamRepository( + table_name=table_name, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + # Create a product under the product team + cpm_product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler + + product_cache["DYNAMODB_CLIENT"] = client + product_repo = CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + product_repo.write(cpm_product) + + params = {"organisation_code": ODS_CODE} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + expected_result = json.dumps( + { + "results": [ + { + "org_code": ODS_CODE, + "product_teams": [ + { + "product_team_id": product_team.id, + "products": [cpm_product.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 +def test_search_multiple(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team = org.create_product_team(name=PRODUCT_TEAM_NAME) + product_team_repo = ProductTeamRepository( + table_name=table_name, dynamodb_client=client + ) + product_team_repo.write(entity=product_team) + + cpm_product = product_team.create_cpm_product( + name=PRODUCT_NAME, product_id=PRODUCT_ID + ) + cpm_product_2 = product_team.create_cpm_product(name="product 2") + cpm_product_3 = product_team.create_cpm_product(name="product 3") + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler + + product_cache["DYNAMODB_CLIENT"] = client + product_repo = CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + product_repo.write(cpm_product) + product_repo.write(cpm_product_2) + product_repo.write(cpm_product_3) + + params = {"product_team_id": str(product_team.id)} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + 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"][0]["product_teams"][0]["products"], + [product.state() for product in [cpm_product, cpm_product_2, cpm_product_3]], + ) + + +@pytest.mark.integration +def test_index_org_code_multiple_product_teams(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + client = dynamodb_client_with_sleep() + org = Root.create_ods_organisation(ods_code=ODS_CODE) + product_team_1 = org.create_product_team( + name=PRODUCT_TEAM_NAME, keys=PRODUCT_TEAM_KEYS + ) + product_team_2 = org.create_product_team(name="product team name 2") + product_team_repo = ProductTeamRepository( + table_name=table_name, dynamodb_client=client + ) + product_team_repo.write(entity=product_team_1) + product_team_repo.write(entity=product_team_2) + + # Create a product under each of the product teams + cpm_product_1 = product_team_1.create_cpm_product(name=PRODUCT_NAME) + cpm_product_2 = product_team_2.create_cpm_product(name="product name 2") + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler + + product_cache["DYNAMODB_CLIENT"] = client + product_repo = CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + product_repo.write(cpm_product_1) + product_repo.write(cpm_product_2) + + params = {"organisation_code": ODS_CODE} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + response_data = json_loads(result["body"]) + + # Expected response structure (without enforcing order) + expected_response = { + "results": [ + { + "org_code": ODS_CODE, + "product_teams": [ + { + "product_team_id": product_team_1.id, + "products": [cpm_product_1.state()], + }, + { + "product_team_id": product_team_2.id, + "products": [cpm_product_2.state()], + }, + ], + } + ] + } + + # Ensure expected product teams are sorted + expected_product_teams = sorted( + expected_response["results"][0]["product_teams"], + key=lambda team: team["product_team_id"], + ) + + # Sort the products within each product team in the expected response + for team in expected_product_teams: + team["products"] = sorted(team["products"], key=lambda product: product["id"]) + + # Extract response product teams (no sorting needed) + response_product_teams = response_data["results"][0]["product_teams"] + + # Perform assertion + assert response_data["results"][0]["org_code"] == ODS_CODE + assert response_product_teams == expected_product_teams + + +@pytest.mark.integration +@pytest.mark.parametrize( + "query_params, expected_status, expected_error", + [ + ( + None, + 400, + f"SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: {ALLOWED_PARAMS}.", + ), + ( + {"organisation_code": "ODS_CODE", "product_team_id": "PRODUCT_TEAM_ID"}, + 400, + f"SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: {ALLOWED_PARAMS}.", + ), + ( + {"organisation_code": "ODS_CODE", "FOO": "BAR"}, + 400, + "SearchProductQueryParams.FOO: extra fields not permitted", + ), + ], +) +def test_index_invalid_query_params(query_params, expected_status, expected_error): + table_name = read_terraform_output("dynamodb_epr_table_name.value") + client = dynamodb_client_with_sleep() + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import cache as product_cache + from api.searchProduct.index import handler as handler + + product_cache["DYNAMODB_CLIENT"] = client + + CpmProductRepository( + table_name=product_cache["DYNAMODB_TABLE"], + dynamodb_client=product_cache["DYNAMODB_CLIENT"], + ) + + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": query_params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + result_body = json_loads(result["body"]) + + assert result["statusCode"] == expected_status + assert result_body["errors"][0]["code"] == "VALIDATION_ERROR" + assert result_body["errors"][0]["message"] == expected_error + + +@pytest.mark.integration +def test_index_no_product_team(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import handler + + params = {"product_team_id": PRODUCT_TEAM_ID} + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + result_body = json_loads(result["body"]) + + assert result["statusCode"] == 200 + assert result_body == {"results": []} + + +@pytest.mark.integration +def test_index_no_org(): + table_name = read_terraform_output("dynamodb_cpm_table_name.value") + + with mock.patch.dict( + os.environ, + { + "DYNAMODB_TABLE": table_name, + "AWS_DEFAULT_REGION": "eu-west-2", + }, + clear=True, + ): + from api.searchProduct.index import handler + + params = { + "organisation_code": ODS_CODE, + } + result = handler( + event={ + "headers": {"version": VERSION}, + "queryStringParameters": params, + "multiValueHeaders": {"Host": ["foo.co.uk"]}, + } + ) + + result_body = json_loads(result["body"]) + + assert result["statusCode"] == 200 + assert result_body == {"results": []} diff --git a/src/api/tests/feature_tests/environment.py b/src/api/tests/feature_tests/environment.py index ecfbcb868..14e6f4fc4 100644 --- a/src/api/tests/feature_tests/environment.py +++ b/src/api/tests/feature_tests/environment.py @@ -96,6 +96,8 @@ def before_feature(context: Context, feature: Feature): "Read CPM Product - failure scenarios", "Delete CPM Product - success scenarios", "Delete CPM Product - failure scenarios", + "Search Products - success scenarios", + "Search Products - failures scenarios", ] if context.test_mode is TestMode.INTEGRATION: table = ( diff --git a/src/api/tests/feature_tests/features/searchCpmProduct.failure.feature b/src/api/tests/feature_tests/features/searchCpmProduct.failure.feature new file mode 100644 index 000000000..ac4599681 --- /dev/null +++ b/src/api/tests/feature_tests/features/searchCpmProduct.failure.feature @@ -0,0 +1,41 @@ +Feature: Search Products - failures scenarios + These scenarios demonstrate unsuccessful Product Search + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Unsuccessfully search a Product without query params + When I make a "GET" request with "default" headers to "Product" + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: ('product_team_id', 'organisation_code'). | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 183 | + + Scenario: Unsuccessfully search a Product with unknown query param + When I make a "GET" request with "default" headers to "Product?product_team_id=123&foo=bar" + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | SearchProductQueryParams.foo: extra fields not permitted | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 113 | + + Scenario: Unsuccessfully search a Product with too many query param + When I make a "GET" request with "default" headers to "Product?product_team_id=123&organisation_code=XYZ" + Then I receive a status code "400" with body + | path | value | + | errors.0.code | VALIDATION_ERROR | + | errors.0.message | SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: ('product_team_id', 'organisation_code'). | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 183 | diff --git a/src/api/tests/feature_tests/features/searchCpmProduct.success.feature b/src/api/tests/feature_tests/features/searchCpmProduct.success.feature new file mode 100644 index 000000000..9d1fd0c20 --- /dev/null +++ b/src/api/tests/feature_tests/features/searchCpmProduct.success.feature @@ -0,0 +1,251 @@ +Feature: Search Products - success scenarios + These scenarios demonstrate successful Product Search + + Background: + Given "default" request headers: + | name | value | + | version | 1 | + | Authorization | letmein | + + Scenario: Successfully search Products 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 | + | 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 "Product?product_team_id=${ note(product_team_id) }" + 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 Outline: Successfully search one Product with product team id or alias + 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 "GET" request with "default" headers to "Product?product_team_id=${ note(product_team_id) }" + Then I receive a status code "200" with body + | path | value | + | results.0.org_code | F5H1R | + | results.0.product_teams.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.id | ${ note(product_id) } | + | results.0.product_teams.0.products.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.name | My Great Product | + | results.0.product_teams.0.products.0.ods_code | F5H1R | + | results.0.product_teams.0.products.0.status | active | + | results.0.product_teams.0.products.0.created_on | << ignore >> | + | results.0.product_teams.0.products.0.updated_on | null | + | results.0.product_teams.0.products.0.deleted_on | null | + | results.0.product_teams.0.products.0.keys | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 381 | + + Examples: + | product_team_id | + | ${ note(product_team_id) } | + | FOOBAR | + + Scenario: Successfully search one Product with oragnisation code + 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 "GET" request with "default" headers to "Product?organisation_code=F5H1R" + Then I receive a status code "200" with body + | path | value | + | results.0.org_code | F5H1R | + | results.0.product_teams.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.id | ${ note(product_id) } | + | results.0.product_teams.0.products.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.name | My Great Product | + | results.0.product_teams.0.products.0.ods_code | F5H1R | + | results.0.product_teams.0.products.0.status | active | + | results.0.product_teams.0.products.0.created_on | << ignore >> | + | results.0.product_teams.0.products.0.updated_on | null | + | results.0.product_teams.0.products.0.deleted_on | null | + | results.0.product_teams.0.products.0.keys | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 381 | + + Scenario Outline: Successfully search more than one Product with product team id or alias + 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 1 | + And I note the response field "$.id" as "product_id_1" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product 2 | + And I note the response field "$.id" as "product_id_2" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product" with body: + | path | value | + | name | My Great Product 3 | + And I note the response field "$.id" as "product_id_3" + When I make a "GET" request with "default" headers to "Product?product_team_id=${ note(product_team_id) }" + Then I receive a status code "200" with body where ProductTeams has a length of "1" with "3" Products each + | path | value | + | results.0.org_code | F5H1R | + | results.0.product_teams.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.id | ${ note(product_id_1) } | + | results.0.product_teams.0.products.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.name | My Great Product 1 | + | results.0.product_teams.0.products.0.ods_code | F5H1R | + | results.0.product_teams.0.products.0.status | active | + | results.0.product_teams.0.products.0.created_on | << ignore >> | + | results.0.product_teams.0.products.0.updated_on | null | + | results.0.product_teams.0.products.0.deleted_on | null | + | results.0.product_teams.0.products.0.keys | [] | + | results.0.product_teams.0.products.1.id | ${ note(product_id_2) } | + | results.0.product_teams.0.products.1.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.1.name | My Great Product 2 | + | results.0.product_teams.0.products.1.ods_code | F5H1R | + | results.0.product_teams.0.products.1.status | active | + | results.0.product_teams.0.products.1.created_on | << ignore >> | + | results.0.product_teams.0.products.1.updated_on | null | + | results.0.product_teams.0.products.1.deleted_on | null | + | results.0.product_teams.0.products.1.keys | [] | + | results.0.product_teams.0.products.2.id | ${ note(product_id_3) } | + | results.0.product_teams.0.products.2.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.2.name | My Great Product 3 | + | results.0.product_teams.0.products.2.ods_code | F5H1R | + | results.0.product_teams.0.products.2.status | active | + | results.0.product_teams.0.products.2.created_on | << ignore >> | + | results.0.product_teams.0.products.2.updated_on | null | + | results.0.product_teams.0.products.2.deleted_on | null | + | results.0.product_teams.0.products.2.keys | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 889 | + + Examples: + | product_team_id | + | ${ note(product_team_id) } | + | FOOBAR | + + Scenario: Successfully search Products under multiple product teams + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team 1 | + | ods_code | F5H1R | + Given I note the response field "$.id" as "product_team_id_1" + Given I have already made a "POST" request with "default" headers to "ProductTeam" with body: + | path | value | + | name | My Great Product Team 2 | + | ods_code | F5H1R | + Given I note the response field "$.id" as "product_team_id_2" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id_1) }/Product" with body: + | path | value | + | name | My Great Product 1 | + And I note the response field "$.id" as "product_id_1" + When I make a "POST" request with "default" headers to "ProductTeam/${ note(product_team_id_2) }/Product" with body: + | path | value | + | name | My Great Product 2 | + And I note the response field "$.id" as "product_id_2" + When I make a "GET" request with "default" headers to "Product?organisation_code=F5H1R" + Then I receive a status code "200" with body where ProductTeams has a length of "2" with "2" Products each + | path | value | + | results.0.org_code | F5H1R | + | results.0.product_teams.0.product_team_id | ${ note(product_team_id_1) } | + | results.0.product_teams.0.products.0.id | ${ note(product_id_1) } | + | results.0.product_teams.0.products.0.product_team_id | ${ note(product_team_id_1) } | + | results.0.product_teams.0.products.0.name | My Great Product 1 | + | results.0.product_teams.0.products.0.ods_code | F5H1R | + | results.0.product_teams.0.products.0.status | active | + | results.0.product_teams.0.products.0.created_on | << ignore >> | + | results.0.product_teams.0.products.0.updated_on | null | + | results.0.product_teams.0.products.0.deleted_on | null | + | results.0.product_teams.0.products.0.keys | [] | + | results.0.product_teams.1.product_team_id | ${ note(product_team_id_2) } | + | results.0.product_teams.1.products.0.id | ${ note(product_id_2) } | + | results.0.product_teams.1.products.0.product_team_id | ${ note(product_team_id_2) } | + | results.0.product_teams.1.products.0.name | My Great Product 2 | + | results.0.product_teams.1.products.0.ods_code | F5H1R | + | results.0.product_teams.1.products.0.status | active | + | results.0.product_teams.1.products.0.created_on | << ignore >> | + | results.0.product_teams.1.products.0.updated_on | null | + | results.0.product_teams.1.products.0.deleted_on | null | + | results.0.product_teams.1.products.0.keys | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 711 | + + Scenario: Deleted Products not returned in search + 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" + 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 1 | + And I note the response field "$.id" as "product_id_1" + 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 2 | + And I note the response field "$.id" as "product_id_2" + 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 3 | + And I note the response field "$.id" as "product_id_3" + And I have already made a "DELETE" request with "default" headers to "ProductTeam/${ note(product_team_id) }/Product/${ note(product_id_2) }" + When I make a "GET" request with "default" headers to "Product?product_team_id=${ note(product_team_id) }" + Then I receive a status code "200" with body where ProductTeams has a length of "1" with "2" Products each + | path | value | + | results.0.org_code | F5H1R | + | results.0.product_teams.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.id | ${ note(product_id_1) } | + | results.0.product_teams.0.products.0.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.0.name | My Great Product 1 | + | results.0.product_teams.0.products.0.ods_code | F5H1R | + | results.0.product_teams.0.products.0.status | active | + | results.0.product_teams.0.products.0.created_on | << ignore >> | + | results.0.product_teams.0.products.0.updated_on | null | + | results.0.product_teams.0.products.0.deleted_on | null | + | results.0.product_teams.0.products.0.keys | [] | + | results.0.product_teams.0.products.1.id | ${ note(product_id_3) } | + | results.0.product_teams.0.products.1.product_team_id | ${ note(product_team_id) } | + | results.0.product_teams.0.products.1.name | My Great Product 3 | + | results.0.product_teams.0.products.1.ods_code | F5H1R | + | results.0.product_teams.0.products.1.status | active | + | results.0.product_teams.0.products.1.created_on | << ignore >> | + | results.0.product_teams.0.products.1.updated_on | null | + | results.0.product_teams.0.products.1.deleted_on | null | + | results.0.product_teams.0.products.1.keys | [] | + And the response headers contain: + | name | value | + | Content-Type | application/json | + | Content-Length | 636 | 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 aa30e7bdd..45e3a7079 100644 --- a/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py +++ b/src/api/tests/feature_tests/steps/endpoint_lambda_mapping.py @@ -27,6 +27,7 @@ def get_endpoint_lambda_mapping() -> ENDPOINT_LAMBDA_MAPPING: import api.deleteProductTeam.index import api.readCpmProduct.index import api.readProductTeam.index + import api.searchProduct.index import api.status.index return { @@ -37,6 +38,9 @@ def get_endpoint_lambda_mapping() -> ENDPOINT_LAMBDA_MAPPING: "GET": { "ProductTeam/{product_team_id}": api.readProductTeam.index, "ProductTeam/{product_team_id}/Product/{product_id}": api.readCpmProduct.index, + "Product?product_team_id={product_team_id}": api.searchProduct.index, + "Product?organisation_code={organisation_code}": api.searchProduct.index, + "Product?foo={foo}": api.searchProduct.index, "_status": api.status.index, }, "DELETE": { diff --git a/src/api/tests/feature_tests/steps/steps.py b/src/api/tests/feature_tests/steps/steps.py index faf68f7f8..d7c16d59c 100644 --- a/src/api/tests/feature_tests/steps/steps.py +++ b/src/api/tests/feature_tests/steps/steps.py @@ -184,14 +184,15 @@ def then_response(context: Context, status_code: str, list_to_check: str, count: response_body = context.response.json() except JSONDecodeError: response_body = context.response.text - assert len(response_body[list_to_check]) == int(count) if list_to_check == "results": + assert len(response_body["results"]) == int(count) expected_body[list_to_check] = sorted( expected_body[list_to_check], key=lambda x: x["name"] ) response_body[list_to_check] = sorted( response_body[list_to_check], key=lambda x: x["name"] ) + assert_many( assertions=( assert_equal, @@ -212,16 +213,46 @@ def then_response(context: Context, status_code: str, list_to_check: str, count: @then( - 'I receive a status code "{status_code}" with a "{entity_type}" search body response that contains' + 'I receive a status code "{status_code}" with body where ProductTeams has a length of "{product_team_count}" with "{product_count}" Products each' ) -def then_response(context: Context, status_code: str, entity_type: str): +def then_response( + context: Context, status_code: str, product_team_count: str, product_count: str +): expected_body = parse_table(table=context.table, context=context) - expected_body = sorted(expected_body, key=lambda x: x[sort_keys[entity_type]]) try: response_body = context.response.json() except JSONDecodeError: response_body = context.response.text - response_body = sorted(response_body, key=lambda x: x[sort_keys[entity_type]]) + + len_product_teams = len(response_body["results"][0]["product_teams"]) + assert len_product_teams == int(product_team_count) + + len_products = 0 + for i in range(len_product_teams): + len_products += len(response_body["results"][0]["product_teams"][i]["products"]) + assert len_products == int(product_count) + + # Sort product teams by "product_team_id" + expected_body["results"][0]["product_teams"] = sorted( + expected_body["results"][0]["product_teams"], + key=lambda team: team["product_team_id"], + ) + response_body["results"][0]["product_teams"] = sorted( + response_body["results"][0]["product_teams"], + key=lambda team: team["product_team_id"], + ) + + # Sort products inside each product team by "id" + for i, _ in enumerate(expected_body["results"][0]["product_teams"]): + expected_body["results"][0]["product_teams"][i]["products"] = sorted( + expected_body["results"][0]["product_teams"][i]["products"], + key=lambda product: product["id"], + ) + response_body["results"][0]["product_teams"][i]["products"] = sorted( + response_body["results"][0]["product_teams"][i]["products"], + key=lambda product: product["id"], + ) + assert_many( assertions=( assert_equal, 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 2ed31d911..05dd7ddc5 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 @@ -134,6 +134,19 @@ def test_parse_path_delete_cpm_product(): ) +def test_parse_path_search_cpm_product(): + with api_lambda_environment_variables(): + import api.searchProduct.index + + endpoint_lambda_mapping = get_endpoint_lambda_mapping() + + assert parse_api_path( + method="GET", + path="Product", + endpoint_lambda_mapping=endpoint_lambda_mapping, + ) == ({}, {}, api.searchProduct.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 028907f4c..bf10b41a8 100644 --- a/src/api/tests/smoke_tests/test_smoke.py +++ b/src/api/tests/smoke_tests/test_smoke.py @@ -74,6 +74,15 @@ def _request(base_url: str, headers: dict, path: str, method: str): "DELETE", 404, ], + [ + "/Product", + "GET", + 400, + ["VALIDATION_ERROR"], + [ + "SearchProductQueryParams.__root__: Please provide exactly one valid query parameter: ('product_team_id', 'organisation_code')." + ], + ], ], ) def test_smoke_tests(request_details): diff --git a/src/layers/domain/repository/cpm_product_repository/tests/v1/test_cpm_product_repository_v1.py b/src/layers/domain/repository/cpm_product_repository/tests/v1/test_cpm_product_repository_v1.py index f473f6f59..f89617514 100644 --- a/src/layers/domain/repository/cpm_product_repository/tests/v1/test_cpm_product_repository_v1.py +++ b/src/layers/domain/repository/cpm_product_repository/tests/v1/test_cpm_product_repository_v1.py @@ -1,6 +1,7 @@ import pytest from domain.core.cpm_product import CpmProduct from domain.core.cpm_system_id import ProductId +from domain.core.enum import Status from domain.core.root import Root from domain.repository.cpm_product_repository import CpmProductRepository from domain.repository.errors import AlreadyExistsError, ItemNotFound @@ -82,7 +83,9 @@ def test__query_products_by_product_team(): name="cpm-product-name-2", product_id=product_id.id ) repo.write(cpm_product_2) - result = repo.search(product_team_id=product_team.id) + result = repo.search_by_product_team( + product_team_id=product_team.id, status=Status.ACTIVE + ) assert len(result) == 2 assert isinstance(result[0], CpmProduct) assert isinstance(result[1], CpmProduct) @@ -114,7 +117,9 @@ def test__query_products_by_product_team_a(): name="cpm-product-name-3", product_id=product_id.id ) repo.write(cpm_product_3) - result = repo.search(product_team_id=product_team_a.id) + result = repo.search_by_product_team( + product_team_id=product_team_a.id, status=Status.ACTIVE + ) assert len(result) == 2 assert isinstance(result[0], CpmProduct) assert isinstance(result[1], CpmProduct) @@ -154,7 +159,9 @@ def test__query_products_by_product_team_with_sk_prefix(): client = dynamodb_client() client.put_item(**args) - result = repo.search(product_team_id=product_team.id) + result = repo.search_by_product_team( + product_team_id=product_team.id, status=Status.ACTIVE + ) assert len(result) == 2 assert isinstance(result[0], CpmProduct) assert isinstance(result[1], CpmProduct) @@ -164,11 +171,11 @@ def test__query_products_by_product_team_with_sk_prefix(): def test__cpm_product_repository_search(): product_id = "P.XXX-YYY" - product_team = _create_product_team() cpm_product = product_team.create_cpm_product( name="cpm-product-name", product_id=product_id ) + organisation_code = CPM_PRODUCT_TEAM_NO_ID["ods_code"] with mock_table_cpm("my_table") as client: repo = CpmProductRepository( @@ -177,6 +184,15 @@ def test__cpm_product_repository_search(): ) repo.write(cpm_product) - result = repo.search(product_team_id=product_team.id) - assert result == [cpm_product] + # Test search by product team (search_by_product_team) + result_by_product_team = repo.search_by_product_team( + product_team_id=product_team.id, status=Status.ACTIVE + ) + assert result_by_product_team == [cpm_product] + + # Test search by organisation (search_by_organisation) + result_by_organisation = repo.search_by_organisation( + organisation_code=organisation_code, status=Status.ACTIVE + ) + assert result_by_organisation == [cpm_product] diff --git a/src/layers/domain/repository/cpm_product_repository/v1.py b/src/layers/domain/repository/cpm_product_repository/v1.py index fba1ce7d6..40c570037 100644 --- a/src/layers/domain/repository/cpm_product_repository/v1.py +++ b/src/layers/domain/repository/cpm_product_repository/v1.py @@ -24,8 +24,27 @@ def __init__(self, table_name: str, dynamodb_client): def read(self, product_team_id: str, id: str, status: str = "active"): return super()._read(parent_ids=(product_team_id,), id=id, status=status) - def search(self, product_team_id: str, status: str = "active"): - return super()._search(parent_ids=(product_team_id,), status=status) + def search_by_product_team( + self, product_team_id: str, status: str + ) -> list[CpmProduct]: + """Search for products under a given Product Team.""" + return super()._search( + parent_ids=(TableKey.PRODUCT_TEAM.key(product_team_id),), + sk_prefix="P#", + status=status, + ) + + def search_by_organisation( + self, organisation_code: str, status: str + ) -> list[CpmProduct]: + """Search for products under a given Organisation using idx_gsi_read_2.""" + self.parent_table_keys = (TableKey.ORG_CODE,) + return super()._search( + parent_ids=(TableKey.ORG_CODE.key(organisation_code),), + sk_prefix="P#", + gsi="idx_gsi_read_2", + status=status, + ) def handle_CpmProductCreatedEvent(self, event: CpmProductCreatedEvent): return self.create_index( diff --git a/src/layers/domain/repository/cpm_repository/v1.py b/src/layers/domain/repository/cpm_repository/v1.py index e3e5b9058..970807f31 100644 --- a/src/layers/domain/repository/cpm_repository/v1.py +++ b/src/layers/domain/repository/cpm_repository/v1.py @@ -180,21 +180,41 @@ def delete_index(self, id: str): ) def _query( - self, parent_ids: tuple[str], id: str = None, status: str = "all" + self, + parent_ids: tuple[str], + id: str = None, + status: str = "all", + gsi: str = None, + sk_prefix: str = None, ) -> list[dict]: + """ + Perform a query on the table with optional GSI and sk_prefix. + """ + # Ensure parent_ids are prefixed only once pk = KEY_SEPARATOR.join( - table_key.key(_id) + _id if _id.startswith(table_key.key("")) else table_key.key(_id) for table_key, _id in zip(self.parent_table_keys, parent_ids) ) + pk_attribute_name = "pk_read_2" if gsi == "idx_gsi_read_2" else "pk" + + # Ensure sk_prefix is correctly applied sk = self.table_key.key(id or "") + if sk_prefix and not sk.startswith(sk_prefix): # Avoid double prefixing + sk = sk_prefix + sk + sk_attribute_name = "sk_read_2" if gsi == "idx_gsi_read_2" else "sk" sk_query_type = QueryType.BEGINS_WITH if id is None else QueryType.EQUALS - sk_condition = sk_query_type.format("sk", ":sk") + sk_condition = sk_query_type.format(sk_attribute_name, ":sk") + args = { "TableName": self.table_name, - "KeyConditionExpression": f"pk = :pk AND {sk_condition}", + "KeyConditionExpression": f"{pk_attribute_name} = :pk AND {sk_condition}", "ExpressionAttributeValues": marshall(**{":pk": pk, ":sk": sk}), } + + if gsi: + args["IndexName"] = gsi + if status != "all": args["FilterExpression"] = "#status = :status" args["ExpressionAttributeValues"][":status"] = {"S": status} @@ -205,12 +225,27 @@ def _query( raise TooManyResults(f"Too many results for query ({(*parent_ids, id)})") return list(map(unmarshall, result["Items"])) - def _search(self, parent_ids: tuple[str], status: str = "all") -> list[ModelType]: - return [ - self.model(**item) - for item in self._query(parent_ids=parent_ids, status=status) - if item.get("root") is True - ] + def _search( + self, + parent_ids: tuple[str], + gsi: str = None, + sk_prefix: str = None, + status: str = "all", + ) -> list[ModelType]: + """ + Perform a search query with optional GSI and sk_prefix. + """ + query_params = { + "parent_ids": parent_ids, + "sk_prefix": sk_prefix, + "status": status, + } + + # If a GSI is provided, include it in the query parameters + if gsi: + query_params["gsi"] = gsi + + return [self.model(**item) for item in self._query(**query_params)] def _read(self, parent_ids: tuple[str], id: str, status: str = "all") -> ModelType: items = self._query(parent_ids=parent_ids or (id,), id=id, status=status) diff --git a/src/layers/domain/request_models/v1.py b/src/layers/domain/request_models/v1.py index 78467123b..2f434fd58 100644 --- a/src/layers/domain/request_models/v1.py +++ b/src/layers/domain/request_models/v1.py @@ -7,6 +7,10 @@ from pydantic import BaseModel, Extra, Field, root_validator, validator ALPHANUMERIC_SPACES_AND_UNDERSCORES = r"^[a-zA-Z0-9 _]*$" +ALLOWED_PRODUCT_SEARCH_PARAMS = ( + "product_team_id", + "organisation_code", +) class ProductTeamPathParams(BaseModel, extra=Extra.forbid): @@ -119,3 +123,25 @@ class DevicePathParams(BaseModel, extra=Extra.forbid): product_team_id: str = Field(...) environment: Environment device_id: str = Field(...) + + +class SearchProductQueryParams(BaseModel, extra=Extra.forbid): + product_team_id: str = None + organisation_code: str = None + + @root_validator + def check_filters(cls, values: dict): + # Count the number of non-null parameters + non_empty_params = [ + param for param in values.values() if param is not None and param != 0 + ] + + if len(non_empty_params) != 1: + raise ValueError( + f"Please provide exactly one valid query parameter: {ALLOWED_PRODUCT_SEARCH_PARAMS}." + ) + + return values + + def get_non_null_params(self): + return self.dict(exclude_none=True) diff --git a/src/layers/domain/response/response_models.py b/src/layers/domain/response/response_models.py index c8095a682..75e04aeb6 100644 --- a/src/layers/domain/response/response_models.py +++ b/src/layers/domain/response/response_models.py @@ -3,3 +3,48 @@ class SearchResponse[T](AggregateRoot): results: list[T] + + +class SearchProductResponse(SearchResponse[dict]): + """Wrapper for grouping products by organisation and product team.""" + + def __init__(self, products: list[dict]): + grouped_products = self._group_products(products) + super().__init__(results=grouped_products) + + def _group_products(self, products: list[dict]) -> list[dict]: + organisations = {} + + product_dicts = [product.state() for product in products] + + for product in product_dicts: + org_code = product["ods_code"] + team_id = product["product_team_id"] + + if org_code not in organisations: + organisations[org_code] = {"org_code": org_code, "product_teams": {}} + + if team_id not in organisations[org_code]["product_teams"]: + organisations[org_code]["product_teams"][team_id] = { + "product_team_id": team_id, + "products": [], + } + + organisations[org_code]["product_teams"][team_id]["products"].append( + product + ) + + # Convert to final structured format + return sorted( + [ + { + "org_code": org["org_code"], + "product_teams": sorted( + list(org["product_teams"].values()), + key=lambda team: team["product_team_id"], + ), + } + for org in organisations.values() + ], + key=lambda org: org["org_code"], + )