diff --git a/.github/scripts/assert_project_configured.sh b/.github/scripts/assert_project_configured.sh new file mode 100755 index 0000000..e927e1c --- /dev/null +++ b/.github/scripts/assert_project_configured.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Blocks CD deploy while this repo still looks like the clean_mss_template starter. +set -euo pipefail + +REPO_NAME="${GITHUB_REPOSITORY##*/}" + +FORBIDDEN_REPO_NAMES=( + "clean_mss_template" +) + +FORBIDDEN_STACK_PREFIX="CleanMssTemplate" +FORBIDDEN_PROJECT_TAG="Template" + +failed=0 + +for name in "${FORBIDDEN_REPO_NAMES[@]}"; do + if [[ "$REPO_NAME" == "$name" ]]; then + echo "::error::Repository is still named '${name}'. Rename it before enabling CD deploy." + failed=1 + fi +done + +if [[ "${GITHUB_REPOSITORY}" == "Maua-Dev/clean_mss_template" ]]; then + echo "::error::Refusing to deploy the upstream template repository itself." + failed=1 +fi + +if grep -qE "STACK_NAME=${FORBIDDEN_STACK_PREFIX}" .github/workflows/CD.yml; then + echo "::error::Change STACK_NAME prefix in .github/workflows/CD.yml (still '${FORBIDDEN_STACK_PREFIX}')." + failed=1 +fi + +if grep -qE "['\"]project['\"]: ['\"]${FORBIDDEN_PROJECT_TAG}['\"]" iac/app.py; then + echo "::error::Change the project tag in iac/app.py (still '${FORBIDDEN_PROJECT_TAG}')." + failed=1 +fi + +if [[ "$failed" -ne 0 ]]; then + echo "" + echo "CD deploy is blocked until the template identity is customized." + echo "No AWS resources will be created." + exit 1 +fi + +echo "Template identity looks customized. Deploy may proceed." diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index dcac48a..414d0d1 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -1,9 +1,87 @@ - name: CD on: push: branches: - - main + - dev + - homolog + - prod workflow_dispatch: + +jobs: + deployGuard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assert project is no longer the unmodified template + run: bash .github/scripts/assert_project_configured.sh + env: + GITHUB_REPOSITORY: ${{ github.repository }} + + testApp: + needs: [deployGuard] + uses: maua-dev/ci_workflows_reusable/.github/workflows/pytest_ci_313.yml@V1.3 + + cdkSynth: + needs: [deployGuard] + uses: ./.github/workflows/cdk_synth_ci.yml + + DeployToAWS: + needs: [deployGuard, testApp, cdkSynth] + environment: + name: ${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set AWS Account ID + run: | + if [[ "${{ github.ref_name }}" == "dev" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_DEV }}" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "homolog" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_HOML }}" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "prod" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_PROD }}" >> $GITHUB_ENV + else + echo "Invalid branch name: ${{ github.ref_name }}" && exit 1 + fi + + - name: Setup AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/GithubActionsRole + role-session-name: github-action + + - name: Setting stage and stack name + run: | + echo "STACK_NAME=EpepStackBack${{ github.ref_name }}" >> $GITHUB_ENV + + - name: Installing Dependencies + run: | + npm install -g aws-cdk + pip install -r requirements-app.txt + pip install -r iac/requirements-infra.txt + + - name: DeployWithCDK + working-directory: iac + run: | + cdk synth "$STACK_NAME" + cdk deploy "$STACK_NAME" --require-approval never + env: + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ env.AWS_ACCOUNT_ID }} + STACK_NAME: ${{ env.STACK_NAME }} + STAGE: ${{ github.ref_name }} + GITHUB_REF_NAME: ${{ github.ref_name }} + MSS_NAME: ${{ github.event.repository.name }} + GRAPH_MICROSOFT_ENDPOINT: ${{ vars.GRAPH_MICROSOFT_ENDPOINT }} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4d12145..9c52037 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,4 +7,14 @@ on: jobs: testApp: - uses: maua-dev/ci_workflows_reusable/.github/workflows/pytest_ci.yml@V1.0 + uses: maua-dev/ci_workflows_reusable/.github/workflows/pytest_ci_313.yml@V1.3 + + cdkSynth: + if: > + github.event_name != 'pull_request' && + (github.ref_name == 'dev' || + github.ref_name == 'homolog' || + github.ref_name == 'prod' || + startsWith(github.ref_name, 'feature/infra') || + startsWith(github.ref_name, 'infra/')) + uses: ./.github/workflows/cdk_synth_ci.yml diff --git a/.github/workflows/cdk_synth_ci.yml b/.github/workflows/cdk_synth_ci.yml new file mode 100644 index 0000000..9b44e39 --- /dev/null +++ b/.github/workflows/cdk_synth_ci.yml @@ -0,0 +1,37 @@ +name: CDK Synth + +on: + workflow_call: + +jobs: + cdkSynth: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-app.txt + pip install -r iac/requirements-infra.txt + npm install -g aws-cdk + + - name: CDK synth + working-directory: iac + run: cdk synth + env: + AWS_REGION: sa-east-1 + AWS_ACCOUNT_ID: "000000000000" + STACK_NAME: EpepStackBackCi + STAGE: ci + GITHUB_REF_NAME: ci + MSS_NAME: epep_api + GRAPH_MICROSOFT_ENDPOINT: https://graph.microsoft.com/v1.0/me diff --git a/.gitignore b/.gitignore index 111f3c8..454d09d 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,6 @@ dmypy.json /.vscode/ /.idea/ +.DS_Store iac/local/docker/dynamodb/shared-local-instance.db +iac/local/docker/minio/ diff --git a/iac/.gitignore b/iac/.gitignore index 53b7da0..1f71dff 100644 --- a/iac/.gitignore +++ b/iac/.gitignore @@ -5,6 +5,7 @@ __pycache__ .venv *.egg-info lambda_layer_out_temp/ +build/ # CDK asset staging directory .cdk.staging diff --git a/iac/README.md b/iac/README.md index e3a001c..7387c8b 100644 --- a/iac/README.md +++ b/iac/README.md @@ -34,7 +34,7 @@ If you are a Windows platform, you would activate the virtualenv like this: Once the virtualenv is activated, you can install the required dependencies. ``` -$ pip install -r requirements.txt +$ pip install -r requirements-infra.txt ``` At this point you can now synthesize the CloudFormation template for this code. @@ -44,7 +44,7 @@ $ cdk synth ``` To add additional dependencies, for example other CDK libraries, just add -them to your `setup.py` file and rerun the `pip install -r requirements.txt` +them to your `setup.py` file and rerun the `pip install -r requirements-infra.txt` command. ## Useful commands diff --git a/iac/adjust_layer_directory.py b/iac/adjust_layer_directory.py index ff845c4..0d817fe 100644 --- a/iac/adjust_layer_directory.py +++ b/iac/adjust_layer_directory.py @@ -1,37 +1,60 @@ import os import shutil +import subprocess from pathlib import Path -import sys -IAC_DIRECTORY_NAME = "iac" -SOURCE_DIRECTORY_NAME = "src" -LAMBDA_LAYER_PREFIX = os.path.join("python", "src") - - -def adjust_layer_directory(shared_dir_name: str, destination: str): - # Get the root directory of the source directory - root_directory = Path(__file__).parent.parent - iac_directory = os.path.join(root_directory, IAC_DIRECTORY_NAME) - - print(f"Root directory: {root_directory}") - print(f"Root direcotry files: {os.listdir(root_directory)}") - print(f"IaC directory: {iac_directory}") - print(f"IaC directory files: {os.listdir(iac_directory)}") - - - # Get the destination and source directory - destination_directory = os.path.join(root_directory, IAC_DIRECTORY_NAME, destination) - source_directory = os.path.join(root_directory, SOURCE_DIRECTORY_NAME, shared_dir_name) - - # Delete the destination directory if it exists - if os.path.exists(destination_directory): - shutil.rmtree(destination_directory) - - # Copy the source directory to the destination directory - shutil.copytree(source_directory, os.path.join(destination_directory, LAMBDA_LAYER_PREFIX, shared_dir_name)) - print( - f"Copying files from {source_directory} to {os.path.join(destination_directory, LAMBDA_LAYER_PREFIX, shared_dir_name)}") - - -if __name__ == '__main__': - adjust_layer_directory(shared_dir_name="shared", destination="lambda_layer_out_temp") \ No newline at end of file +BUILD_DIRECTORY = "build" +PYTHON_TOP_LEVEL_DIR = os.path.join(BUILD_DIRECTORY, "python") +REQUIREMENTS_FILE = "requirements-app.txt" + +PROJECT_ROOT = Path(__file__).parent.parent +SHARED_CODE_SOURCE = os.path.join(PROJECT_ROOT, "src", "shared") + + +def adjust_layer_directory(): + """ + Prepara um diretório 'build' para uma Lambda Layer do AWS CDK. + + A função junta o código local compartilhado e as dependências externas (pip) + na estrutura de pastas que a Lambda espera (/python). + """ + if os.path.exists(BUILD_DIRECTORY): + shutil.rmtree(BUILD_DIRECTORY) + + shared_code_intermediate_dir = os.path.join(PYTHON_TOP_LEVEL_DIR, "src") + os.makedirs(shared_code_intermediate_dir) + + print(f"Copiando código de: {SHARED_CODE_SOURCE}") + shared_code_dest = os.path.join( + shared_code_intermediate_dir, + os.path.basename(SHARED_CODE_SOURCE), + ) + shutil.copytree(SHARED_CODE_SOURCE, shared_code_dest) + + requirements_path = os.path.join(PROJECT_ROOT, REQUIREMENTS_FILE) + if os.path.exists(requirements_path): + subprocess.check_call( + [ + "pip", + "install", + "-r", + requirements_path, + "-t", + PYTHON_TOP_LEVEL_DIR, + "--no-cache-dir", + "--platform", + "manylinux2014_x86_64", + "--python-version", + "3.13", + "--only-binary=:all:", + ] + ) + else: + print( + f"Aviso: Arquivo '{requirements_path}' não encontrado. " + "Nenhuma dependência externa será instalada." + ) + + +if __name__ == "__main__": + adjust_layer_directory() diff --git a/iac/app.py b/iac/app.py index 4b09603..41c741e 100644 --- a/iac/app.py +++ b/iac/app.py @@ -1,17 +1,40 @@ #!/usr/bin/env python3 import os +import re import aws_cdk as cdk -from adjust_layer_directory import adjust_layer_directory - -from iac.template_stack import TemplateStack +from adjust_layer_directory import adjust_layer_directory +from stack.iac_stack import IacStack + +_ALLOWED_STAGES = frozenset({"dev", "homolog", "prod", "ci", "test"}) + + +def resolve_stage() -> str: + """ + STAGE preferencial; GITHUB_REF_NAME só como fallback. + Normaliza para dev|homolog|prod|ci|test sem barras (evita refs de PR tipo N/merge). + """ + raw = (os.environ.get("STAGE") or os.environ.get("GITHUB_REF_NAME") or "dev").strip().lower() + if "/" in raw: + head, _, tail = raw.partition("/") + if head in _ALLOWED_STAGES: + return head + if head == "infra" or head == "feature": + return "ci" + if tail in _ALLOWED_STAGES: + return tail + return "ci" + if raw in _ALLOWED_STAGES: + return raw + sanitized = re.sub(r"[^a-z0-9-]", "-", raw).strip("-") or "ci" + return sanitized if sanitized in _ALLOWED_STAGES else "ci" print("Starting the CDK") print("Adjusting the layer directory") -adjust_layer_directory(shared_dir_name="shared", destination="lambda_layer_out_temp") +adjust_layer_directory() print("Finished adjusting the layer directory") @@ -20,27 +43,25 @@ aws_region = os.environ.get("AWS_REGION") aws_account_id = os.environ.get("AWS_ACCOUNT_ID") stack_name = os.environ.get("STACK_NAME") - -if 'prod' in stack_name: - stage = 'PROD' - -elif 'homolog' in stack_name: - stage = 'HOMOLOG' - -elif 'dev' in stack_name: - stage = 'DEV' - -else: - stage = 'TEST' +stage = resolve_stage() tags = { - 'project': 'Template', - 'stage': stage, - 'stack': 'BACK', - 'owner': 'DevCommunity' + "project": "EpepApi", + "stage": stage, + "stack": stack_name, + "owner": "DevCommunity", } -TemplateStack(app, stack_name=stack_name, env=cdk.Environment(account=aws_account_id, region=aws_region), tags=tags) - +IacStack( + app, + stack_id=stack_name, + stack_name=stack_name, + stage=stage, + env=cdk.Environment( + account=aws_account_id, + region=aws_region, + ), + tags=tags, +) app.synth() diff --git a/iac/cdk.json b/iac/cdk.json index 99841c9..def2d49 100644 --- a/iac/cdk.json +++ b/iac/cdk.json @@ -1,5 +1,5 @@ { - "app": "python app.py", + "app": "python -m app", "watch": { "include": [ "**" diff --git a/iac/components/apigw_construct.py b/iac/components/apigw_construct.py new file mode 100644 index 0000000..623dbca --- /dev/null +++ b/iac/components/apigw_construct.py @@ -0,0 +1,80 @@ +from aws_cdk import aws_apigateway as apigateway +from constructs import Construct +from aws_cdk.aws_apigateway import ( + Cors, + CorsOptions, + GatewayResponse, + Resource, + ResponseType, + RestApi, +) + + +class ApigwConstruct(Construct): + + rest_api: RestApi + api_gateway_resource: Resource + + def __init__( + self, + scope: Construct, + construct_id: str, + stack_name: str, + stage: str, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + stage_lower = stage.lower() + + cors_options = CorsOptions( + allow_origins=Cors.ALL_ORIGINS, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=Cors.DEFAULT_HEADERS, + ) + + self.rest_api = RestApi( + self, + id="RestApi", + rest_api_name=f"{stack_name}-RestApi-{stage_lower}", + description=f"{stack_name} RestApi for {stage_lower}", + deploy_options=apigateway.StageOptions( + stage_name=stage_lower, + logging_level=apigateway.MethodLoggingLevel.OFF, + data_trace_enabled=False, + metrics_enabled=True, + tracing_enabled=True, + ), + default_cors_preflight_options=cors_options, + ) + + self.api_gateway_resource = self.rest_api.root.add_resource( + path_part="epep-api", + default_cors_preflight_options=cors_options, + ) + + GatewayResponse( + self, + "AuthorizerDenyResponse", + rest_api=self.rest_api, + type=ResponseType.ACCESS_DENIED, + response_headers={ + "Access-Control-Allow-Origin": "'*'", + "Access-Control-Allow-Headers": "'*'", + "Access-Control-Allow-Methods": "'*'", + }, + status_code="403", + ) + + GatewayResponse( + self, + "AuthorizerUnauthorizedResponse", + rest_api=self.rest_api, + type=ResponseType.UNAUTHORIZED, + response_headers={ + "Access-Control-Allow-Origin": "'*'", + "Access-Control-Allow-Headers": "'*'", + "Access-Control-Allow-Methods": "'*'", + }, + status_code="401", + ) diff --git a/iac/components/dynamo_construct.py b/iac/components/dynamo_construct.py new file mode 100644 index 0000000..3040032 --- /dev/null +++ b/iac/components/dynamo_construct.py @@ -0,0 +1,64 @@ +from aws_cdk import ( + RemovalPolicy, + aws_dynamodb as dynamodb, +) +from constructs import Construct + +# Mantenha alinhado com src.shared.infra.external.dynamo.dynamo_keys / Environments +_EPEP_TABLE_PREFIX = "EpepTable" +_USER_EMAIL_INDEX_NAME = "UserEmailIndex" + +RETAINED_STAGES = {"prod", "homolog"} + + +class DynamoConstruct(Construct): + + epep_table: dynamodb.Table + + def __init__( + self, + scope: Construct, + construct_id: str, + stack_name: str, + stage: str, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + stage_lower = stage.lower() + + removal_policy = ( + RemovalPolicy.RETAIN if stage_lower in RETAINED_STAGES else RemovalPolicy.DESTROY + ) + + self.epep_table = dynamodb.Table( + self, + id="EpepTable", + partition_key=dynamodb.Attribute( + name="pk", + type=dynamodb.AttributeType.STRING, + ), + sort_key=dynamodb.Attribute( + name="sk", + type=dynamodb.AttributeType.STRING, + ), + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + table_name=f"{_EPEP_TABLE_PREFIX}-{stage_lower}", + point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification( + point_in_time_recovery_enabled=(stage_lower == "prod") + ), + ) + + self.epep_table.add_global_secondary_index( + index_name=_USER_EMAIL_INDEX_NAME, + partition_key=dynamodb.Attribute( + name="gsi2pk", + type=dynamodb.AttributeType.STRING, + ), + sort_key=dynamodb.Attribute( + name="gsi2sk", + type=dynamodb.AttributeType.STRING, + ), + projection_type=dynamodb.ProjectionType.ALL, + ) diff --git a/iac/components/lambda_construct.py b/iac/components/lambda_construct.py new file mode 100644 index 0000000..bdc79c9 --- /dev/null +++ b/iac/components/lambda_construct.py @@ -0,0 +1,124 @@ +from typing import Optional + +from aws_cdk import ( + aws_apigateway as apigw, + aws_lambda as lambda_, + Duration, +) +from constructs import Construct +from aws_cdk.aws_apigateway import Resource, LambdaIntegration + + +class LambdaConstruct(Construct): + + stage: str + stack_name: str + functions_that_need_dynamo_db_access: list[lambda_.Function] + lambda_layer: lambda_.LayerVersion + + def create_lambda_function( + self, + module_name: str, + environment_variables: dict, + subfolder: str = "", + ) -> lambda_.Function: + code = ( + lambda_.Code.from_asset(f"../src/modules/{subfolder}/{module_name}") + if subfolder + else lambda_.Code.from_asset(f"../src/modules/{module_name}") + ) + return lambda_.Function( + self, + module_name.title().replace("_", ""), + code=code, + handler=f"app.{module_name}_presenter.lambda_handler", + function_name=f"{module_name}-{self.stack_name}-{self.stage}"[:63], + runtime=lambda_.Runtime.PYTHON_3_13, + layers=[self.lambda_layer], + environment=environment_variables, + timeout=Duration.seconds(60), + memory_size=512, + tracing=lambda_.Tracing.ACTIVE, + ) + + def add_method_to_resource( + self, + resource: Resource, + method: str, + function: lambda_.Function, + authorizer: Optional[apigw.IAuthorizer] = None, + api_key_required: bool = False, + ) -> None: + method_options = { + "integration": LambdaIntegration(function), + "api_key_required": api_key_required, + } + if authorizer is not None: + method_options["authorization_type"] = apigw.AuthorizationType.CUSTOM + method_options["authorizer"] = authorizer + else: + method_options["authorization_type"] = apigw.AuthorizationType.NONE + + resource.add_method(method, **method_options) + + def __init__( + self, + scope: Construct, + construct_id: str, + stage: str, + stack_name: str, + api_gateway_resource: Resource, + environment_variables: dict, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.stage = stage + self.stack_name = stack_name + self.functions_that_need_dynamo_db_access = [] + + self.lambda_layer = lambda_.LayerVersion( + self, + id=f"{stack_name}_LambdaLayer_{stage}", + layer_version_name=f"{stack_name}-LambdaLayer-{self.stage}", + code=lambda_.Code.from_asset("./build"), + compatible_runtimes=[lambda_.Runtime.PYTHON_3_13], + ) + + users_resource = api_gateway_resource.add_resource("users") + user_id_resource = users_resource.add_resource("{user_id}") + + self.create_user = self.create_lambda_function( + module_name="create_user", + environment_variables=environment_variables, + ) + self.add_method_to_resource(users_resource, "POST", self.create_user) + self.functions_that_need_dynamo_db_access.append(self.create_user) + + self.get_all_users = self.create_lambda_function( + module_name="get_all_users", + environment_variables=environment_variables, + ) + self.add_method_to_resource(users_resource, "GET", self.get_all_users) + self.functions_that_need_dynamo_db_access.append(self.get_all_users) + + self.get_user = self.create_lambda_function( + module_name="get_user", + environment_variables=environment_variables, + ) + self.add_method_to_resource(user_id_resource, "GET", self.get_user) + self.functions_that_need_dynamo_db_access.append(self.get_user) + + self.update_user = self.create_lambda_function( + module_name="update_user", + environment_variables=environment_variables, + ) + self.add_method_to_resource(user_id_resource, "PUT", self.update_user) + self.functions_that_need_dynamo_db_access.append(self.update_user) + + self.delete_user = self.create_lambda_function( + module_name="delete_user", + environment_variables=environment_variables, + ) + self.add_method_to_resource(user_id_resource, "DELETE", self.delete_user) + self.functions_that_need_dynamo_db_access.append(self.delete_user) diff --git a/iac/components/ssm_construct.py b/iac/components/ssm_construct.py new file mode 100644 index 0000000..da60bec --- /dev/null +++ b/iac/components/ssm_construct.py @@ -0,0 +1,49 @@ +from constructs import Construct +from aws_cdk import Resource, aws_ssm as ssm +from aws_cdk.aws_apigateway import RestApi +from aws_cdk import aws_s3 as s3 + + +class SsmConstruct(Construct): + + def __init__( + self, + scope: Construct, + construct_id: str, + stage: str, + stack_name: str, + api: RestApi, + api_gateway_resource: Resource, + buckets: dict[str, s3.Bucket] = None, + extra_params: dict[str, str] = None, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + stage = stage.lower() + path_prefix = stack_name.lower().replace("-", "_") + + if api: + ssm.StringParameter( + self, + id=f"ApiUrl_{stage}", + parameter_name=f"/{path_prefix}/{stage}/api/url", + string_value=f"{api.url}{api_gateway_resource.path.lstrip('/')}/", + ) + + for logical_name, bucket in (buckets or {}).items(): + ssm.StringParameter( + self, + id=f"Bucket_{logical_name}_{stage}", + parameter_name=f"/{path_prefix}/{stage}/buckets/{logical_name}", + string_value=bucket.bucket_name, + ) + + for key, value in (extra_params or {}).items(): + safe_id = key.replace("/", "_") + ssm.StringParameter( + self, + id=f"Extra_{safe_id}_{stage}", + parameter_name=f"/{path_prefix}/{stage}/{key}", + string_value=value, + ) diff --git a/iac/iac/lambda_stack.py b/iac/iac/lambda_stack.py deleted file mode 100644 index 8187d82..0000000 --- a/iac/iac/lambda_stack.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from aws_cdk import ( - aws_lambda as lambda_, - NestedStack, Duration -) -from constructs import Construct -from aws_cdk.aws_apigateway import Resource, LambdaIntegration - - -class LambdaStack(Construct): - functions_that_need_dynamo_permissions = [] - - def create_lambda_api_gateway_integration(self, module_name: str, method: str, mss_student_api_resource: Resource, - environment_variables: dict = {"STAGE": "TEST"}): - function = lambda_.Function( - self, module_name.title(), - code=lambda_.Code.from_asset(f"../src/modules/{module_name}"), - handler=f"app.{module_name}_presenter.lambda_handler", - runtime=lambda_.Runtime.PYTHON_3_9, - layers=[self.lambda_layer, self.lambda_power_tools], - environment=environment_variables, - timeout=Duration.seconds(15) - ) - - mss_student_api_resource.add_resource(module_name.replace("_", "-")).add_method(method, - integration=LambdaIntegration( - function)) - - return function - - def __init__(self, scope: Construct, api_gateway_resource: Resource, environment_variables: dict) -> None: - super().__init__(scope, "Template_Lambdas") - - self.lambda_layer = lambda_.LayerVersion(self, "Template_Layer", - code=lambda_.Code.from_asset("./lambda_layer_out_temp"), - compatible_runtimes=[lambda_.Runtime.PYTHON_3_9] - ) - - self.lambda_power_tools = lambda_.LayerVersion.from_layer_version_arn(self, "Lambda_Power_Tools", layer_version_arn="arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:22") - - self.get_user_function = self.create_lambda_api_gateway_integration( - module_name="get_user", - method="GET", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.create_user_function = self.create_lambda_api_gateway_integration( - module_name="create_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.delete_user_function = self.create_lambda_api_gateway_integration( - module_name="delete_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.update_user_function = self.create_lambda_api_gateway_integration( - module_name="update_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.functions_that_need_dynamo_permissions = [self.get_user_function, self.create_user_function, - self.delete_user_function, self.update_user_function] diff --git a/iac/iac/template_dynamo_table.py b/iac/iac/template_dynamo_table.py deleted file mode 100644 index 9bc82dc..0000000 --- a/iac/iac/template_dynamo_table.py +++ /dev/null @@ -1,30 +0,0 @@ -from decimal import Decimal -from aws_cdk import ( - aws_dynamodb as dynamodb, RemovalPolicy, -) -from constructs import Construct - - -class TemplateDynamoTable(Construct): - table: dynamodb.Table - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.table = dynamodb.Table( - self, "TemplateDynamoTable", - partition_key=dynamodb.Attribute( - name="PK", - type=dynamodb.AttributeType.STRING - ), - sort_key=dynamodb.Attribute( - name="SK", - type=dynamodb.AttributeType.STRING - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - removal_policy=RemovalPolicy.DESTROY - ) - - - - diff --git a/iac/iac/template_stack.py b/iac/iac/template_stack.py deleted file mode 100644 index dbaf9b4..0000000 --- a/iac/iac/template_stack.py +++ /dev/null @@ -1,55 +0,0 @@ -from aws_cdk import ( - # Duration, - Stack, - # aws_sqs as sqs, -) -from constructs import Construct -from aws_cdk.aws_apigateway import RestApi, Cors - -from .lambda_stack import LambdaStack -from .template_dynamo_table import TemplateDynamoTable - - -class TemplateStack(Stack): - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.rest_api = RestApi(self, "Template_RestApi", - rest_api_name="Template_RestApi", - description="This is the Template RestApi", - default_cors_preflight_options= - { - "allow_origins": Cors.ALL_ORIGINS, - "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": ["*"] - }, - ) - - api_gateway_resource = self.rest_api.root.add_resource("mss-template", default_cors_preflight_options= - { - "allow_origins": Cors.ALL_ORIGINS, - "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": Cors.DEFAULT_HEADERS - } - ) - - self.dynamo_table = TemplateDynamoTable(self, "TemplateDynamoTable") - - ENVIRONMENT_VARIABLES = { - "STAGE": "DEV", - "DYNAMO_TABLE_NAME": self.dynamo_table.table.table_name, - "DYNAMO_PARTITION_KEY": "PK", - "DYNAMO_SORT_KEY": "SK", - "REGION": self.region, - } - - - - self.lambda_stack = LambdaStack(self, api_gateway_resource=api_gateway_resource, - environment_variables=ENVIRONMENT_VARIABLES) - - for function in self.lambda_stack.functions_that_need_dynamo_permissions: - self.dynamo_table.table.grant_read_write_data(function) - - \ No newline at end of file diff --git a/iac/local/README.md b/iac/local/README.md index facf220..c887e0d 100644 --- a/iac/local/README.md +++ b/iac/local/README.md @@ -4,13 +4,50 @@ - have docker installed and running - have [sam](https://docs.aws.amazon.com/serverless-application-model/index.html) installed -## Setup dynamodb local -- `docker run -p 8000:8000 amazon/dynamodb-local -sharedDB -inMemory` -- run `src/shared/infra/repositories/load_user_mock_to_dynamo.py` +## Start local services (DynamoDB + MinIO) +From `iac/local`: + +```bash +docker compose up -d +``` + +| Service | URL / port | Notes | +|---------|------------|--------| +| DynamoDB Local | `http://localhost:8000` | persistence in `./docker/dynamodb` | +| MinIO S3 API | `http://localhost:9000` | credentials `minioadmin` / `minioadmin` | +| MinIO Console | http://localhost:9001 | web UI | + +On first boot, `minio-init` creates the bucket `bucket-test` (same name as `Environments.s3_bucket_name` in `STAGE=TEST`). + +### Seed DynamoDB +From the repo root, with `STAGE=TEST` in `.env`: + +```bash +python -m src.shared.infra.repositories.load_user_mock_to_dynamo +``` + +### Integration tests (DynamoDB Local) +Os testes `test_*_repository_dynamo.py` estão comentados — **não rodam no CI**. + +Para rodar localmente: suba o Docker, rode o seed, reative o teste e execute o pytest normalmente. Sem Dynamo up, o teste falha. + +### Point boto3 / code at MinIO (example) + +```python +import boto3 + +s3 = boto3.client( + "s3", + endpoint_url="http://localhost:9000", + aws_access_key_id="minioadmin", + aws_secret_access_key="minioadmin", + region_name="us-east-1", +) +s3.list_objects_v2(Bucket="bucket-test") +``` ## Build cdk (/iac) - `cdk synth` - `sam build -t ./cdk.out/IacStack.template.json` - `sam local start-api -t ./cdk.out/IacStack.template.json` -- diff --git a/iac/local/configure_dynamodb.md b/iac/local/configure_dynamodb.md index 0e80d1d..5ace3d4 100644 --- a/iac/local/configure_dynamodb.md +++ b/iac/local/configure_dynamodb.md @@ -38,7 +38,7 @@ Next, you need to configure DynamoDB for your project and Docker compose. Follow ``` cd iac cd local - docker-compose up -d + docker compose up -d ``` ### Create virtual enviroments in python (once for project) @@ -49,7 +49,7 @@ Next, you need to configure DynamoDB for your project and Docker compose. Follow ###### Linux - virtualenv -p python3.9 venv + virtualenv -p python3.11 venv ### Activate the venv @@ -81,41 +81,13 @@ Start the DynamoDB Local container using Docker: ## Running the `load_user_mock_to_dynamo` Script -Finally, you can run the `load_user_mock_to_dynamo` script to load mock data into DynamoDB. Follow the steps below: +Seed mock `User` data into DynamoDB Local. From the repo root, with `STAGE=TEST`: -1. Locate the directory or file named `load_user_mock_to_dynamo` within your project. This directory or file is responsible for loading mock data into DynamoDB -2. If the `load_user_mock_to_dynamo` file doesn't exist, you need to create it. -3. Once you have located or created the `load_user_mock_to_dynamo` file, make sure it is in the correct location within your project structure. The file should be located in the `src/shared/infra/repositories` ```bash -. -├── iac -├── src -│ ├── ... -│ │ -│ │ -│ └── shared -│ ├── domain -│ │ └── ... -│ │ -│ ├── helpers -│ │ └── ... -│ │ -│ └── infra -│ ├── dto -│ ├── external -│ └── repositories -│ └── -> [load_user_mock_to_dynamo] <- -... +python -m src.shared.infra.repositories.load_user_mock_to_dynamo ``` -4. This file is responsible for populating DynamoDB with mock data -5. Then, make sure you are in the root directory of your project again -6. Run the following command to execute the script: - ``` - py -m src.shared.infra.repositories.load_user_mock_to_dynamo - ``` - -This command will run the `load_user_mock_to_dynamo` script and populate DynamoDB with the provided mock data +The script lives at `src/shared/infra/repositories/load_user_mock_to_dynamo.py`. It creates the local table if missing, then loads users from `UserRepositoryMock`. ## Launch NoSQL WorkBench @@ -124,4 +96,4 @@ Lastly, you need to set NoSQL WorkBench to receive and visualize data runned. Fo 1. Launch DynamoDB 2. Open Operation builder and add a connection 3. Select DynamoDB Local -4. Open Local \ No newline at end of file +4. Open Local diff --git a/iac/local/docker-compose.yml b/iac/local/docker-compose.yml index 28b9de0..c5db5f6 100644 --- a/iac/local/docker-compose.yml +++ b/iac/local/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: dynamodb-local: command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data" @@ -10,8 +9,46 @@ services: - "./docker/dynamodb:/home/dynamodblocal/data" working_dir: /home/dynamodblocal networks: - - dynamodb-local + - local + + minio: + image: minio/minio:latest + container_name: minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" # S3 API + - "9001:9001" # Console web + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - "./docker/minio:/data" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + networks: + - local + + # Cria o bucket alinhado a Environments.s3_bucket_name (STAGE=TEST) + minio-init: + image: minio/mc:latest + container_name: minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin && + mc mb --ignore-existing local/bucket-test && + mc anonymous set download local/bucket-test && + echo 'MinIO ready: bucket bucket-test' + " + networks: + - local networks: - dynamodb-local: - name: dynamodb-local + local: + name: local diff --git a/iac/requirements-dev.txt b/iac/requirements-dev.txt deleted file mode 100644 index 9270945..0000000 --- a/iac/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pytest==6.2.5 diff --git a/iac/requirements-infra.txt b/iac/requirements-infra.txt new file mode 100644 index 0000000..eb15e80 --- /dev/null +++ b/iac/requirements-infra.txt @@ -0,0 +1 @@ +aws-cdk-lib==2.211.0 diff --git a/iac/requirements.txt b/iac/requirements.txt deleted file mode 100644 index 82ff4b9..0000000 --- a/iac/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -aws-cdk-lib==2.25.0 -constructs>=10.0.0,<11.0.0 -aws-lambda-powertools==2.9.0 -aws-xray-sdk==2.11.0 -pytest==6.2.5 \ No newline at end of file diff --git a/iac/stack/iac_stack.py b/iac/stack/iac_stack.py new file mode 100644 index 0000000..db79b33 --- /dev/null +++ b/iac/stack/iac_stack.py @@ -0,0 +1,70 @@ +import os + +from aws_cdk import Stack +from constructs import Construct + +from components.apigw_construct import ApigwConstruct +from components.dynamo_construct import DynamoConstruct +from components.lambda_construct import LambdaConstruct +from components.ssm_construct import SsmConstruct + + +class IacStack(Stack): + + def __init__( + self, + scope: Construct, + stack_id: str, + stack_name: str, + stage: str, + **kwargs, + ) -> None: + super().__init__(scope, stack_id, **kwargs) + + self.apigw_construct = ApigwConstruct( + self, + construct_id="Apigw", + stack_name=stack_name, + stage=stage, + ) + + self.dynamo_construct = DynamoConstruct( + self, + construct_id="Dynamo", + stack_name=stack_name, + stage=stage, + ) + + ENVIRONMENT_VARIABLES = { + "STAGE": stage.upper(), + "REGION": self.region, + "DYNAMO_TABLE_NAME": self.dynamo_construct.epep_table.table_name, + "DYNAMO_PARTITION_KEY": "pk", + "DYNAMO_SORT_KEY": "sk", + "MSS_NAME": stack_name, + "GRAPH_MICROSOFT_ENDPOINT": os.environ.get( + "GRAPH_MICROSOFT_ENDPOINT", + "https://graph.microsoft.com/v1.0/me", + ), + } + + self.lambda_construct = LambdaConstruct( + self, + construct_id="Lambda", + api_gateway_resource=self.apigw_construct.api_gateway_resource, + stage=stage, + stack_name=stack_name, + environment_variables=ENVIRONMENT_VARIABLES, + ) + + self.ssm_construct = SsmConstruct( + self, + construct_id=f"{stack_name}SystemsManager", + stack_name=stack_name, + stage=stage, + api=self.apigw_construct.rest_api, + api_gateway_resource=self.apigw_construct.api_gateway_resource, + ) + + for function in self.lambda_construct.functions_that_need_dynamo_db_access: + self.dynamo_construct.epep_table.grant_read_write_data(function) diff --git a/requirements-app.txt b/requirements-app.txt new file mode 100644 index 0000000..8ddb8fa --- /dev/null +++ b/requirements-app.txt @@ -0,0 +1,6 @@ +# pacotes necessários na lambda +pydantic==2.11.7 +email-validator==2.2.0 +urllib3>=2.0.0 +# só usado por ObservabilityAWS (wrapper); testes locais usam ObservabilityMock +aws-lambda-powertools>=3.0.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index ab21f95..4678fdc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,5 @@ -pytest==6.2.5 -pytest-cov==4.0.0 -boto3==1.24.88 -python-dotenv==0.21.0 -aws-lambda-powertools==2.9.0 -aws_xray_sdk==2.11.0 \ No newline at end of file +# pacotes necessários em local (lambda não usa) +-r requirements-app.txt +pytest==8.4.1 +pytest-cov==6.2.1 +boto3==1.43.60 diff --git a/src/modules/create_user/app/create_user_controller.py b/src/modules/create_user/app/create_user_controller.py index 29e3a59..c0d65ea 100644 --- a/src/modules/create_user/app/create_user_controller.py +++ b/src/modules/create_user/app/create_user_controller.py @@ -14,13 +14,10 @@ def __init__(self, usecase: CreateUserUsecase): def __call__(self, request: IRequest) -> IResponse: try: - if request.data.get('name') is None: - raise MissingParameters('name') if request.data.get('email') is None: raise MissingParameters('email') - + user = self.CreateUserUsecase( - name=request.data.get('name'), email=request.data.get('email') ) diff --git a/src/modules/create_user/app/create_user_presenter.py b/src/modules/create_user/app/create_user_presenter.py index 042f5de..c279e83 100644 --- a/src/modules/create_user/app/create_user_presenter.py +++ b/src/modules/create_user/app/create_user_presenter.py @@ -2,20 +2,20 @@ from .create_user_usecase import CreateUserUsecase from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse +from src.shared.helpers.observability.wrap_handler import observed_handler repo = Environments.get_user_repo()() usecase = CreateUserUsecase(repo) controller = CreateUserController(usecase) -def lambda_handler(event, context): - - from pprint import pprint - - pprint(event) - - httpRequest = LambdaHttpRequest(data=event) - response = controller(httpRequest) - httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) - - return httpResponse.toDict() +@observed_handler("create_user") +def lambda_handler(event, context): + http_request = LambdaHttpRequest(data=event) + response = controller(http_request) + http_response = LambdaHttpResponse( + status_code=response.status_code, + body=response.body, + headers=response.headers + ) + return http_response.toDict() diff --git a/src/modules/create_user/app/create_user_usecase.py b/src/modules/create_user/app/create_user_usecase.py index e1e9e80..f1f2d17 100644 --- a/src/modules/create_user/app/create_user_usecase.py +++ b/src/modules/create_user/app/create_user_usecase.py @@ -1,7 +1,7 @@ - +from pydantic import EmailStr from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.helpers.errors.domain_errors import EntityError @@ -10,18 +10,11 @@ class CreateUserUsecase: def __init__(self, repo: IUserRepository): self.repo = repo - def __call__(self, name: str, email: str) -> User: - - if not User.validate_name(name): - raise EntityError("name") - - if not User.validate_email(email): - raise EntityError("email") + def __call__(self, email: str) -> User: user = User( - name=name, email=email, - state=STATE.PENDING + role=ROLE.USER ) return self.repo.create_user(user) diff --git a/src/modules/create_user/app/create_user_viewmodel.py b/src/modules/create_user/app/create_user_viewmodel.py index fd71ebc..d374350 100644 --- a/src/modules/create_user/app/create_user_viewmodel.py +++ b/src/modules/create_user/app/create_user_viewmodel.py @@ -1,24 +1,17 @@ +from uuid import UUID from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE class CreateUserViewmodel: - user_id: int - name: str + user_id: UUID email: str - state: STATE + role: ROLE def __init__(self, user: User): - self.user_id = user.user_id - self.name = user.name - self.email = user.email - self.state = user.state + self.user = user def to_dict(self): - return { - 'user_id': self.user_id, - 'name': self.name, - 'email': self.email, - 'state': self.state.value, - 'message': "the user was created successfully" - } + data = self.user.model_dump(mode='json') + data['message'] = "the user was created successfully" + return data diff --git a/src/modules/delete_user/app/delete_user_controller.py b/src/modules/delete_user/app/delete_user_controller.py index d69e104..ef3eef4 100644 --- a/src/modules/delete_user/app/delete_user_controller.py +++ b/src/modules/delete_user/app/delete_user_controller.py @@ -1,3 +1,4 @@ +from uuid import UUID from src.shared.helpers.errors.usecase_errors import NoItemsFound from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse from .delete_user_usecase import DeleteUserUsecase @@ -17,17 +18,20 @@ def __call__(self, request: IRequest) -> IResponse: if request.data.get('user_id') is None: raise MissingParameters('user_id') - if type(request.data.get('user_id')) != str: + if not isinstance(request.data.get('user_id'), str): raise WrongTypeParameter( fieldName="user_id", fieldTypeExpected="str", fieldTypeReceived=request.data.get('user_id').__class__.__name__ ) - if not request.data.get('user_id').isdecimal(): - raise EntityError("user_id") + + try: + user_id = UUID(request.data.get('user_id')) + except: + raise EntityError('user_id') user = self.DeleteUserUsecase( - user_id=int(request.data.get('user_id')) + user_id=user_id ) viewmodel = DeleteUserViewmodel(user=user) diff --git a/src/modules/delete_user/app/delete_user_presenter.py b/src/modules/delete_user/app/delete_user_presenter.py index edd46de..1ef7db5 100644 --- a/src/modules/delete_user/app/delete_user_presenter.py +++ b/src/modules/delete_user/app/delete_user_presenter.py @@ -2,15 +2,20 @@ from .delete_user_usecase import DeleteUserUsecase from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse +from src.shared.helpers.observability.wrap_handler import observed_handler repo = Environments.get_user_repo()() usecase = DeleteUserUsecase(repo) controller = DeleteUserController(usecase) +@observed_handler("delete_user") def lambda_handler(event, context): - httpRequest = LambdaHttpRequest(data=event) - response = controller(httpRequest) - httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) - - return httpResponse.toDict() + http_request = LambdaHttpRequest(data=event) + response = controller(http_request) + http_response = LambdaHttpResponse( + status_code=response.status_code, + body=response.body, + headers=response.headers + ) + return http_response.toDict() diff --git a/src/modules/delete_user/app/delete_user_usecase.py b/src/modules/delete_user/app/delete_user_usecase.py index 2321587..88091fc 100644 --- a/src/modules/delete_user/app/delete_user_usecase.py +++ b/src/modules/delete_user/app/delete_user_usecase.py @@ -1,3 +1,4 @@ +from uuid import UUID from src.shared.domain.entities.user import User from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.helpers.errors.domain_errors import EntityError @@ -7,12 +8,7 @@ class DeleteUserUsecase: def __init__(self, repo: IUserRepository): self.repo = repo - def __call__(self, user_id: int) -> User: - - - if type(user_id) != int: - raise EntityError("user_id") - + def __call__(self, user_id: UUID) -> User: user = self.repo.delete_user(user_id) return user diff --git a/src/modules/delete_user/app/delete_user_viewmodel.py b/src/modules/delete_user/app/delete_user_viewmodel.py index 83b6a6e..cc9879a 100644 --- a/src/modules/delete_user/app/delete_user_viewmodel.py +++ b/src/modules/delete_user/app/delete_user_viewmodel.py @@ -9,16 +9,10 @@ class DeleteUserViewmodel: state: STATE def __init__(self, user: User): - self.user_id = user.user_id - self.name = user.name - self.email = user.email - self.state = user.state + self.data = user.model_dump(mode='json') def to_dict(self): return { - 'user_id': self.user_id, - 'name': self.name, - 'email': self.email, - 'state': self.state.value, + **self.data, 'message': "the user was deleted successfully" } diff --git a/src/modules/get_all_users/app/get_all_users_presenter.py b/src/modules/get_all_users/app/get_all_users_presenter.py index 114735b..7154de8 100644 --- a/src/modules/get_all_users/app/get_all_users_presenter.py +++ b/src/modules/get_all_users/app/get_all_users_presenter.py @@ -3,15 +3,20 @@ from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse +from src.shared.helpers.observability.wrap_handler import observed_handler repo: IUserRepository = Environments.get_user_repo()() usecase = GetAllUsersUsecase(repo) controller = GetAllUsersController(usecase) +@observed_handler("get_all_users") def lambda_handler(event, context): - httpRequest = LambdaHttpRequest(data=event) - response = controller(httpRequest) - httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) - - return httpResponse.toDict() + http_request = LambdaHttpRequest(data=event) + response = controller(http_request) + http_response = LambdaHttpResponse( + status_code=response.status_code, + body=response.body, + headers=response.headers + ) + return http_response.toDict() diff --git a/src/modules/get_all_users/app/get_all_users_viewmodel.py b/src/modules/get_all_users/app/get_all_users_viewmodel.py index 32ff93c..192774d 100644 --- a/src/modules/get_all_users/app/get_all_users_viewmodel.py +++ b/src/modules/get_all_users/app/get_all_users_viewmodel.py @@ -5,18 +5,10 @@ class UserViewmodel: def __init__(self, user: User): - self.state = user.state - self.email = user.email - self.name = user.name - self.user_id = user.user_id + self.user = user def to_dict(self): - return { - 'user_id': self.user_id, - 'name': self.name, - 'email': self.email, - 'state': self.state.value - } + return self.user.model_dump(mode='json') class GetAllUsersViewmodel: diff --git a/src/modules/get_user/app/get_user_controller.py b/src/modules/get_user/app/get_user_controller.py index 392869e..d0ef4e5 100644 --- a/src/modules/get_user/app/get_user_controller.py +++ b/src/modules/get_user/app/get_user_controller.py @@ -1,3 +1,4 @@ +from uuid import UUID from src.shared.infra.external.observability.observability_aws import ObservabilityAWS from .get_user_usecase import GetUserUsecase from .get_user_viewmodel import GetUserViewmodel @@ -29,13 +30,13 @@ def __call__(self, request: IRequest) -> IResponse: fieldTypeReceived=request.data.get('user_id').__class__.__name__ ) - if not request.data.get('user_id').isdecimal(): - raise EntityError("user_id") - + try: + user_id = UUID(request.data.get('user_id')) + except: + raise EntityError('user_id') user = self.GetUserUsecase( - user_id=int(request.data.get('user_id')) - ) + user_id=user_id) viewmodel = GetUserViewmodel(user) diff --git a/src/modules/get_user/app/get_user_presenter.py b/src/modules/get_user/app/get_user_presenter.py index c00d098..b87634a 100644 --- a/src/modules/get_user/app/get_user_presenter.py +++ b/src/modules/get_user/app/get_user_presenter.py @@ -2,9 +2,7 @@ from .get_user_usecase import GetUserUsecase from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse -from src.shared.infra.external.observability.observability_aws import ObservabilityAWS -from aws_lambda_powertools import Logger, Tracer, Metrics - +from src.shared.helpers.observability.wrap_handler import observed_handler observability = Environments.get_observability()(module_name="get_user") @@ -12,19 +10,14 @@ usecase = GetUserUsecase(repo, observability=observability) controller = GetUserController(usecase, observability=observability) -@observability.presenter_decorators -def get_user_presenter(event): - httpRequest = LambdaHttpRequest(data=event) - response = controller(httpRequest) - httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) - return httpResponse.toDict() -@observability.handler_decorators +@observed_handler("get_user") def lambda_handler(event, context): - - response = get_user_presenter(event) - - observability.add_metric(name="ErrorCount", unit="Count", value=1) if response["statusCode"] != 200 else None # ErrorCount metrics - - return response - + http_request = LambdaHttpRequest(data=event) + response = controller(http_request) + http_response = LambdaHttpResponse( + status_code=response.status_code, + body=response.body, + headers=response.headers + ) + return http_response.toDict() diff --git a/src/modules/get_user/app/get_user_usecase.py b/src/modules/get_user/app/get_user_usecase.py index 4377d1d..92aeebe 100644 --- a/src/modules/get_user/app/get_user_usecase.py +++ b/src/modules/get_user/app/get_user_usecase.py @@ -1,3 +1,4 @@ +from uuid import UUID from src.shared.domain.entities.user import User from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.helpers.errors.domain_errors import EntityError @@ -9,10 +10,10 @@ def __init__(self, repo: IUserRepository, observability: ObservabilityAWS): self.repo = repo self.observability = observability - def __call__(self, user_id: int) -> User: + def __call__(self, user_id: UUID) -> User: self.observability.log_usecase_in() - if type(user_id) != int: - raise EntityError("user_id") + if not isinstance(user_id, UUID): + raise EntityError('user_id') user = self.repo.get_user(user_id) self.observability.log_usecase_out() return user diff --git a/src/modules/get_user/app/get_user_viewmodel.py b/src/modules/get_user/app/get_user_viewmodel.py index bb4be7f..9a6fd50 100644 --- a/src/modules/get_user/app/get_user_viewmodel.py +++ b/src/modules/get_user/app/get_user_viewmodel.py @@ -1,24 +1,14 @@ from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE class GetUserViewmodel: - user_id: int - name: str - email: str - state: STATE + user: User def __init__(self, user: User): - self.user_id = user.user_id - self.name = user.name - self.email = user.email - self.state = user.state + self.user = user def to_dict(self): - return { - 'user_id': self.user_id, - 'name': self.name, - 'email': self.email, - 'state': self.state.value, - 'message': "the user was retrieved successfully" - } + data = self.user.model_dump(mode='json') + data.update({'message': "the user was retrieved successfully"}) + return data diff --git a/src/modules/update_user/app/update_user_controller.py b/src/modules/update_user/app/update_user_controller.py index 5a2e703..5ee6716 100644 --- a/src/modules/update_user/app/update_user_controller.py +++ b/src/modules/update_user/app/update_user_controller.py @@ -1,3 +1,4 @@ +from uuid import UUID from src.shared.helpers.external_interfaces.external_interface import IResponse, IRequest from .update_user_usecase import UpdateUserUsecase from .update_user_viewmodel import UpdateUserViewmodel @@ -16,17 +17,38 @@ def __call__(self, request: IRequest) -> IResponse: try: if request.data.get('user_id') is None: raise MissingParameters('user_id') - if request.data.get('new_name') is None: - raise MissingParameters('new_name') - if type(request.data.get('user_id')) != str: + if not isinstance(request.data.get('user_id'), str): raise WrongTypeParameter( fieldName="user_id", fieldTypeExpected="str", fieldTypeReceived=request.data.get('user_id').__class__.__name__ ) - user = self.UpdateUserUsecase(user_id=int(request.data.get('user_id')), new_name=request.data.get('new_name')) + if request.data.get('new_email') is not None and not isinstance(request.data.get('new_email'), str): + raise WrongTypeParameter( + fieldName="new_email", + fieldTypeExpected="str", + fieldTypeReceived=request.data.get('new_email').__class__.__name__ + ) + + if request.data.get('new_role') is not None and not isinstance(request.data.get('new_role'), str): + raise WrongTypeParameter( + fieldName="new_role", + fieldTypeExpected="str", + fieldTypeReceived=request.data.get('new_role').__class__.__name__ + ) + + try: + user_id = UUID(request.data.get('user_id')) + except ValueError: + raise EntityError('user_id') + + user = self.UpdateUserUsecase( + user_id=user_id, + new_email=request.data.get('new_email'), + new_role=request.data.get('new_role') + ) viewmodel = UpdateUserViewmodel(user=user) diff --git a/src/modules/update_user/app/update_user_presenter.py b/src/modules/update_user/app/update_user_presenter.py index d651263..7cb2ac3 100644 --- a/src/modules/update_user/app/update_user_presenter.py +++ b/src/modules/update_user/app/update_user_presenter.py @@ -2,15 +2,20 @@ from .update_user_usecase import UpdateUserUsecase from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse +from src.shared.helpers.observability.wrap_handler import observed_handler repo = Environments.get_user_repo()() usecase = UpdateUserUsecase(repo) controller = UpdateUserController(usecase) +@observed_handler("update_user") def lambda_handler(event, context): - httpRequest = LambdaHttpRequest(data=event) - response = controller(httpRequest) - httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) - - return httpResponse.toDict() + http_request = LambdaHttpRequest(data=event) + response = controller(http_request) + http_response = LambdaHttpResponse( + status_code=response.status_code, + body=response.body, + headers=response.headers + ) + return http_response.toDict() diff --git a/src/modules/update_user/app/update_user_usecase.py b/src/modules/update_user/app/update_user_usecase.py index 1c5511b..5875a18 100644 --- a/src/modules/update_user/app/update_user_usecase.py +++ b/src/modules/update_user/app/update_user_usecase.py @@ -1,20 +1,24 @@ +from uuid import UUID from src.shared.domain.entities.user import User from src.shared.domain.repositories.user_repository_interface import IUserRepository -from src.shared.helpers.errors.domain_errors import EntityError class UpdateUserUsecase: def __init__(self, repo: IUserRepository): self.repo = repo - def __call__(self, user_id: int, new_name: str) -> User: - - if type(user_id) != int: - raise EntityError("user_id") - - if type(new_name) != str: - raise EntityError("new_name") - - updated_user = self.repo.update_user(user_id=user_id, new_name=new_name) + def __call__( + self, + user_id: UUID, + new_email: str | None = None, + new_role: str | None = None + ) -> User: + stored_user = self.repo.get_user(user_id=user_id) + user = User( + user_id=user_id, + email=new_email or stored_user.email, + role=new_role or stored_user.role + ) + updated_user = self.repo.update_user(user=user) return updated_user diff --git a/src/modules/update_user/app/update_user_viewmodel.py b/src/modules/update_user/app/update_user_viewmodel.py index 85bcc49..6169d47 100644 --- a/src/modules/update_user/app/update_user_viewmodel.py +++ b/src/modules/update_user/app/update_user_viewmodel.py @@ -1,25 +1,13 @@ from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE class UpdateUserViewmodel: - user_id: int - name: str - email: str - state: STATE + user: User def __init__(self, user: User): - self.user_id = user.user_id - self.name = user.name - self.email = user.email - self.state = user.state + self.user = user def to_dict(self): - return { - 'user_id': self.user_id, - 'name': self.name, - 'email': self.email, - 'state': self.state.value, - 'message': "the user was updated successfully" - } - + data = self.user.model_dump(mode='json') + data.update({'message': "the user was updated successfully"}) + return data diff --git a/src/shared/domain/entities/member.py b/src/shared/domain/entities/member.py new file mode 100644 index 0000000..7d208b1 --- /dev/null +++ b/src/shared/domain/entities/member.py @@ -0,0 +1,73 @@ +import uuid +from pydantic import BaseModel, Field, ValidationError, HttpUrl, ConfigDict, field_validator + +from src.shared.helpers.errors.domain_errors import EntityError +from src.shared.domain.enums.member_function_enum import MemberFunctionEnum +from src.shared.domain.enums.member_status_enum import MemberStatusEnum + +class Member(BaseModel): + def __init__(self, **data): + try: + super().__init__(**data) + except ValidationError as err: + raise EntityError(str(err.errors()[0]["loc"][0])) from err + + + member_id: uuid.UUID = Field( + description="Identificador único de membro", + default_factory=uuid.uuid4 + ) + + + name: str = Field( + ..., + description="Nome do membro", + min_length=2, + pattern=r"^[a-zA-ZÀ-ÿ\s]+$" + ) + + + function: MemberFunctionEnum = Field( + ..., + description="Cargo do membro" + ) + + + status: MemberStatusEnum = Field( + ..., + description="Status do membro" + ) + + + linkedin: HttpUrl = Field( + ..., + description="Perfil do LinkedIn do membro" + ) + + + photo: HttpUrl = Field( + ..., + description="Foto do membro" + ) + @field_validator("photo") + @classmethod + def validate_photo_extension(cls, url: HttpUrl) -> HttpUrl: + allowed_extensions = (".png", ".jpg", ".jpeg", ".webp") + if not str(url).lower().endswith(allowed_extensions): + raise ValueError("photo must end with .png, .jpg, .jpeg, or .webp") + return url + # Checks the URL's extension format, but not its content. It can still be a non-existent URL, for example + + + description: str = Field( + ..., + description="Descrição do membro", + min_length=5 + ) + + + model_config = ConfigDict( + use_enum_values=True, + extra="forbid", + populate_by_name=True, + ) \ No newline at end of file diff --git a/src/shared/domain/entities/user.py b/src/shared/domain/entities/user.py index 0e4f333..b12d29e 100644 --- a/src/shared/domain/entities/user.py +++ b/src/shared/domain/entities/user.py @@ -1,60 +1,36 @@ -import abc -import re +import uuid +from pydantic import BaseModel, Field, EmailStr, ConfigDict, ValidationError -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE from src.shared.helpers.errors.domain_errors import EntityError -class User(abc.ABC): - name: str - email: str - state: STATE - MIN_NAME_LENGTH = 2 - user_id: int - - def __init__(self, name: str, email: str, state: STATE, user_id: int = None): - if not User.validate_name(name): - raise EntityError("name") - self.name = name - - if not User.validate_email(email): - raise EntityError("email") - self.email = email - - if type(user_id) == int: - if user_id < 0: - raise EntityError("user_id") - - if type(user_id) != int and user_id is not None: - raise EntityError("user_id") - - self.user_id = user_id - - if type(state) != STATE: - raise EntityError("state") - self.state = state - - @staticmethod - def validate_name(name: str) -> bool: - if name is None: - return False - elif type(name) != str: - return False - elif len(name) < User.MIN_NAME_LENGTH: - return False - - return True - - @staticmethod - def validate_email(email: str) -> bool: - if email is None: - return False - - regex = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") - - return bool(re.fullmatch(regex, email)) - - - - def __repr__(self): - return f"User(name={self.name}, email={self.email}, user_id={self.user_id}, state={self.state})" +class User(BaseModel): + def __init__(self, **data): + try: + super().__init__(**data) + except ValidationError as err: + raise EntityError(str(err.errors()[0]["loc"][0])) from err + + user_id: uuid.UUID = Field( + default_factory=uuid.uuid4, + description="Identificador único de usuário" + ) + + email: EmailStr = Field( + ..., + description="Email do usuário", + min_length=5, + examples=["usuario@example.com"] + ) + + role: ROLE = Field( + default=ROLE.USER, + description="Função do usuário", + ) + + model_config = ConfigDict( + use_enum_values=True, + extra="forbid", + populate_by_name=True, + ) diff --git a/src/shared/domain/enums/member_function_enum.py b/src/shared/domain/enums/member_function_enum.py new file mode 100644 index 0000000..f6d39cc --- /dev/null +++ b/src/shared/domain/enums/member_function_enum.py @@ -0,0 +1,12 @@ +from enum import Enum + +class MemberFunctionEnum(Enum): + PRESIDENCIA = "Presidencia" + VICE_PRESIDENCIA = "VicePresidencia" + RH = "RH" + MARKETING = "Marketing" + REDACAO = "Redacao" + EVENTOS = "Eventos" + FINANCEIRO = "Financeiro" + PROJETOS = "Projetos" + \ No newline at end of file diff --git a/src/shared/domain/enums/member_status_enum.py b/src/shared/domain/enums/member_status_enum.py new file mode 100644 index 0000000..6bf060c --- /dev/null +++ b/src/shared/domain/enums/member_status_enum.py @@ -0,0 +1,7 @@ +from enum import Enum + +class MemberStatusEnum(Enum): + ATIVO = "Ativo" + DESATIVADO = "Desativado" + CONGELADO = "Congelado" + \ No newline at end of file diff --git a/src/shared/domain/enums/role_enum.py b/src/shared/domain/enums/role_enum.py new file mode 100644 index 0000000..1319f62 --- /dev/null +++ b/src/shared/domain/enums/role_enum.py @@ -0,0 +1,5 @@ +from enum import Enum + +class ROLE(str, Enum): + ADMIN = "admin" + USER = "user" diff --git a/src/shared/domain/observability/observability_interface.py b/src/shared/domain/observability/observability_interface.py index 1a5b1e4..9e26046 100644 --- a/src/shared/domain/observability/observability_interface.py +++ b/src/shared/domain/observability/observability_interface.py @@ -1,45 +1,44 @@ import os -import time -from aws_lambda_powertools import Logger, Tracer, Metrics from abc import ABC, abstractmethod + class IObservability(ABC): module_name: str mss_name: str - + @abstractmethod def __init__(self, module_name: str) -> None: self.module_name = module_name self.mss_name = os.environ.get("MSS_NAME") - - @abstractmethod + + @abstractmethod def _log_info(self, message: str) -> None: pass - - @abstractmethod + + @abstractmethod def log_controller_in(self) -> None: pass - - @abstractmethod + + @abstractmethod def log_controller_out(self) -> None: pass - - @abstractmethod + + @abstractmethod def log_usecase_in(self) -> None: pass - - @abstractmethod + + @abstractmethod def log_usecase_out(self) -> None: pass - + @abstractmethod def log_exception(self, message: str) -> None: pass - + @abstractmethod def add_metric(self, name: str, unit: str, value: float) -> None: pass - + @abstractmethod def presenter_decorators(self, presenter) -> None: """ @@ -47,10 +46,10 @@ def presenter_decorators(self, presenter) -> None: 1. calculate the processing time of the presenter 2. use the @tracer.capture_method to trace the presenter """ - def presenter_wrapper(event): + def presenter_wrapper(event): pass pass - + @abstractmethod def handler_decorators(self, handler) -> None: """ @@ -59,6 +58,6 @@ def handler_decorators(self, handler) -> None: 2. use the @metrics.log_metrics(capture_cold_start_metric=True, default_dimensions={"environment": os.getenv("STAGE", "dev"), "another": "one"}) # ColdStart metrics and adding dimensions 3. use the @logger.inject_lambda_context(log_event=True) # Log event """ - def handler_wrapper(event, context): + def handler_wrapper(event, context): pass - pass \ No newline at end of file + pass diff --git a/src/shared/domain/repositories/member_repository_interface.py b/src/shared/domain/repositories/member_repository_interface.py new file mode 100644 index 0000000..bc7251a --- /dev/null +++ b/src/shared/domain/repositories/member_repository_interface.py @@ -0,0 +1,37 @@ +import uuid +from abc import ABC, abstractmethod +from typing import List + +from src.shared.domain.entities.member import Member + + +class IMemberRepository(ABC): + + @abstractmethod + def get_member(self, member_id: uuid.UUID) -> Member: + """ + If member not found raise NoItemsFound + """ + pass + + @abstractmethod + def get_all_member(self) -> List[Member]: + pass + + @abstractmethod + def create_member(self, new_member: Member) -> Member: + pass + + @abstractmethod + def delete_member(self, member_id: uuid.UUID) -> Member: + """ + If member not found raise NoItemsFound + """ + pass + + @abstractmethod + def update_member(self, member: Member) -> Member: + """ + If member not found raise NoItemsFound + """ + pass diff --git a/src/shared/domain/repositories/user_repository_interface.py b/src/shared/domain/repositories/user_repository_interface.py index b9c407e..78315ca 100644 --- a/src/shared/domain/repositories/user_repository_interface.py +++ b/src/shared/domain/repositories/user_repository_interface.py @@ -1,3 +1,4 @@ +import uuid from abc import ABC, abstractmethod from typing import List @@ -7,7 +8,7 @@ class IUserRepository(ABC): @abstractmethod - def get_user(self, user_id: int) -> User: + def get_user(self, user_id: uuid.UUID) -> User: """ If user not found raise NoItemsFound """ @@ -22,22 +23,15 @@ def create_user(self, new_user: User) -> User: pass @abstractmethod - def delete_user(self, user_id: int) -> User: + def delete_user(self, user_id: uuid.UUID) -> User: """ If user not found raise NoItemsFound """ pass @abstractmethod - def update_user(self, user_id: int, new_name: str) -> User: + def update_user(self, user: User) -> User: """ If user not found raise NoItemsFound """ pass - - @abstractmethod - def get_user_counter(self) -> int: - """ - Returns the number of all users that have ever been created - """ - pass diff --git a/src/shared/environments.py b/src/shared/environments.py index ce8c825..77e81be 100644 --- a/src/shared/environments.py +++ b/src/shared/environments.py @@ -24,7 +24,7 @@ class Environments: stage: STAGE s3_bucket_name: str region: str - endpoint_url: str = None + dynamo_endpoint_url: str = None # DynamoDB Local (ex: http://localhost:8000); None na AWS dynamo_table_name: str dynamo_partition_key: str dynamo_sort_key: str @@ -46,16 +46,16 @@ def load_envs(self): if self.stage == STAGE.TEST: self.s3_bucket_name = "bucket-test" self.region = "sa-east-1" - self.endpoint_url = "http://localhost:8000" + self.dynamo_endpoint_url = "http://localhost:8000" self.dynamo_table_name = "user_mss_template-table" - self.dynamo_partition_key = "PK" - self.dynamo_sort_key = "SK" + self.dynamo_partition_key = "pk" + self.dynamo_sort_key = "sk" self.cloud_front_distribution_domain = "https://d3q9q9q9q9q9q9.cloudfront.net" else: self.s3_bucket_name = os.environ.get("S3_BUCKET_NAME") self.region = os.environ.get("REGION") - self.endpoint_url = os.environ.get("ENDPOINT_URL") + self.dynamo_endpoint_url = os.environ.get("DYNAMO_ENDPOINT_URL") self.dynamo_table_name = os.environ.get("DYNAMO_TABLE_NAME") self.dynamo_partition_key = os.environ.get("DYNAMO_PARTITION_KEY") self.dynamo_sort_key = os.environ.get("DYNAMO_SORT_KEY") @@ -67,8 +67,8 @@ def get_user_repo() -> IUserRepository: from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock return UserRepositoryMock elif Environments.get_envs().stage in [STAGE.DEV, STAGE.HOMOLOG, STAGE.PROD]: - from src.shared.infra.repositories.user_repository_dynamo import UserRepositoryDynamo - return UserRepositoryDynamo + from src.shared.infra.repositories.template_repository_dynamo import TemplateRepositoryDynamo + return TemplateRepositoryDynamo else: raise Exception("No repository found for this stage") @@ -86,7 +86,7 @@ def get_observability() -> IObservability: def get_envs() -> "Environments": """ Returns the Environments object. This method should be used to get the Environments object instead of instantiating it directly. - :return: Environments (stage={self.stage}, s3_bucket_name={self.s3_bucket_name}, region={self.region}, endpoint_url={self.endpoint_url}) + :return: Environments (stage={self.stage}, region={self.region}, dynamo_table_name={self.dynamo_table_name}, dynamo_endpoint_url={self.dynamo_endpoint_url}) """ envs = Environments() diff --git a/src/shared/helpers/auth/authorizer_user.py b/src/shared/helpers/auth/authorizer_user.py new file mode 100644 index 0000000..7de754f --- /dev/null +++ b/src/shared/helpers/auth/authorizer_user.py @@ -0,0 +1,72 @@ +"""Helpers for API Gateway authorizer → Lambda request. + +Contrato do context: apenas claims Microsoft — sub, mail, name. +Role / User de domínio NÃO entram aqui; isso é responsabilidade do usecase. +""" +from __future__ import annotations + +import json +from typing import Any, Optional + +# chave única em request.data — controllers usam request.data.get(USER_FROM_AUTHORIZER_KEY) +USER_FROM_AUTHORIZER_KEY = "user_from_authorizer" + + +def build_authorizer_user_context(*, sub: str, mail: str, name: str) -> dict[str, str]: + """ + Monta o context do TOKEN authorizer só com claims Microsoft. + + Args: + sub: identificador do usuário no IdP (Graph `id` / JWT `sub`) + mail: e-mail + name: nome de exibição + """ + return { + "user": json.dumps( + { + "sub": sub, + "mail": mail, + "name": name, + } + ) + } + + +def parse_authorizer_user_from_event(event: dict | None) -> Optional[dict[str, Any]]: + """ + Lê o usuário injetado pelo TOKEN authorizer. + + API Gateway REST coloca o context em: + event["requestContext"]["authorizer"]["user"] # string JSON + + Retorno esperado: {"sub", "mail", "name"} ou None. + """ + if not event: + return None + + authorizer = (event.get("requestContext") or {}).get("authorizer") or {} + raw_user = authorizer.get("user") + if raw_user is None: + return None + + if isinstance(raw_user, dict): + parsed = raw_user + elif isinstance(raw_user, str): + try: + parsed = json.loads(raw_user) + except (TypeError, json.JSONDecodeError): + return None + if not isinstance(parsed, dict): + return None + else: + return None + + sub = parsed.get("sub") + mail = parsed.get("mail") + name = parsed.get("name") + if not isinstance(sub, str) or not isinstance(mail, str) or not isinstance(name, str): + return None + if not sub.strip() or not mail.strip(): + return None + + return {"sub": sub, "mail": mail, "name": name} diff --git a/src/shared/helpers/auth/iam_policy.py b/src/shared/helpers/auth/iam_policy.py new file mode 100644 index 0000000..aed1f56 --- /dev/null +++ b/src/shared/helpers/auth/iam_policy.py @@ -0,0 +1,39 @@ +from typing import Any, Optional + + +def generate_policy( + principal_id: str, + effect: str, + method_arn: str, + context: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """ + Build an API Gateway TOKEN authorizer IAM policy response. + + Args: + principal_id: Caller principal (usually Microsoft Graph user id). + effect: "Allow" or "Deny". + method_arn: ARN of the method being authorized. + context: Optional string-keyed context passed to the downstream Lambda. + + Returns: + Authorizer response with principalId, policyDocument and optional context. + """ + auth_response: dict[str, Any] = {"principalId": principal_id} + + if effect: + auth_response["policyDocument"] = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": effect, + "Resource": method_arn, + } + ], + } + + if context: + auth_response["context"] = context + + return auth_response diff --git a/src/shared/helpers/enum/http_status_code_enum.py b/src/shared/helpers/enum/http_status_code_enum.py index 8fac6ca..9ffed88 100644 --- a/src/shared/helpers/enum/http_status_code_enum.py +++ b/src/shared/helpers/enum/http_status_code_enum.py @@ -4,15 +4,25 @@ class HttpStatusCodeEnum(Enum): OK = 200 CREATED = 201 + ACCEPTED = 202 NO_CONTENT = 204 + MOVED_PERMANENTLY = 301 + FOUND = 302 REDIRECT = 303 + NOT_MODIFIED = 304 BAD_REQUEST = 400 UNAUTHORIZED = 401 + PAYMENT_REQUIRED = 402 FORBIDDEN = 403 NOT_FOUND = 404 METHOD_NOT_ALLOWED = 405 + NOT_ACCEPTABLE = 406 CONFLICT = 409 + UNSUPPORTED_MEDIA_TYPE = 415 + UNPROCESSABLE_ENTITY = 422 + TOO_MANY_REQUESTS = 429 INTERNAL_SERVER_ERROR = 500 + NOT_IMPLEMENTED = 501 BAD_GATEWAY = 502 SERVICE_UNAVAILABLE = 503 - GATEWAY_TIMEOUT = 504 \ No newline at end of file + GATEWAY_TIMEOUT = 504 diff --git a/src/shared/helpers/errors/controller_errors.py b/src/shared/helpers/errors/controller_errors.py index c319375..4523d9b 100644 --- a/src/shared/helpers/errors/controller_errors.py +++ b/src/shared/helpers/errors/controller_errors.py @@ -6,4 +6,8 @@ def __init__(self, message: str): super().__init__(f'Field {message} is missing') class WrongTypeParameter(BaseError): def __init__(self, fieldName: str, fieldTypeExpected: str, fieldTypeReceived: str): - super().__init__(f'Field {fieldName} isn\'t in the right type.\n Received: {fieldTypeReceived}.\n Expected: {fieldTypeExpected}') \ No newline at end of file + message = ( + f"The field '{fieldName}' has the wrong type. " + f"Received: '{fieldTypeReceived}'. Expected: '{fieldTypeExpected}'." + ) + super().__init__(message) diff --git a/src/shared/helpers/errors/usecase_errors.py b/src/shared/helpers/errors/usecase_errors.py index bb403d0..867e0df 100644 --- a/src/shared/helpers/errors/usecase_errors.py +++ b/src/shared/helpers/errors/usecase_errors.py @@ -7,8 +7,19 @@ def __init__(self, message: str): class DuplicatedItem(BaseError): def __init__(self, message: str): super().__init__(f'The item alredy exists for this {message}') - + +class NoUsersFound(BaseError): + def __init__(self, message: str): + super().__init__(f'No users found for {message}') + +class DuplicatedUser(BaseError): + def __init__(self, message: str): + super().__init__(f'The user already exists for this {message}') + +class DuplicatedMember(BaseError): + def __init__(self, message: str): + super().__init__(f'The member already exists for this {message}') + class ForbiddenAction(BaseError): def __init__(self, message: str): super().__init__(f'That action is forbidden for this {message}') - diff --git a/src/shared/helpers/external_interfaces/http_codes.py b/src/shared/helpers/external_interfaces/http_codes.py index 3bb8126..21d9166 100644 --- a/src/shared/helpers/external_interfaces/http_codes.py +++ b/src/shared/helpers/external_interfaces/http_codes.py @@ -4,6 +4,7 @@ from src.shared.helpers.external_interfaces.http_models import HttpResponse +# 2xx - Success class OK(HttpResponse): def __init__(self, body: Any = None) -> None: super().__init__(HttpStatusCodeEnum.OK.value, body) @@ -14,19 +15,71 @@ def __init__(self, body: Any = None) -> None: super().__init__(HttpStatusCodeEnum.CREATED.value, body) +class Accepted(HttpResponse): + def __init__(self, body: Any = None) -> None: + super().__init__(HttpStatusCodeEnum.ACCEPTED.value, body) + + class NoContent(HttpResponse): def __init__(self) -> None: super().__init__(HttpStatusCodeEnum.NO_CONTENT.value, None) +# 3xx - Redirection +class MovedPermanently(HttpResponse): + def __init__(self, location: str) -> None: + super().__init__( + HttpStatusCodeEnum.MOVED_PERMANENTLY.value, + None, + headers={"Location": location} + ) + self.location = location + + +class Found(HttpResponse): + def __init__(self, location: str) -> None: + super().__init__( + HttpStatusCodeEnum.FOUND.value, + None, + headers={"Location": location} + ) + self.location = location + + +class RedirectResponse(HttpResponse): + def __init__(self, location: str) -> None: + super().__init__( + HttpStatusCodeEnum.REDIRECT.value, + None, + headers={"Location": location} + ) + self.location = location + + +class NotModified(HttpResponse): + def __init__(self) -> None: + super().__init__(HttpStatusCodeEnum.NOT_MODIFIED.value, None) + + +# 4xx - Client errors class BadRequest(HttpResponse): def __init__(self, body: Any) -> None: super().__init__(HttpStatusCodeEnum.BAD_REQUEST.value, body) -class InternalServerError(HttpResponse): +class Unauthorized(HttpResponse): def __init__(self, body: Any) -> None: - super().__init__(HttpStatusCodeEnum.INTERNAL_SERVER_ERROR.value, body) + super().__init__(HttpStatusCodeEnum.UNAUTHORIZED.value, body) + + +class PaymentRequired(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.PAYMENT_REQUIRED.value, body) + + +class Forbidden(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.FORBIDDEN.value, body) class NotFound(HttpResponse): @@ -34,17 +87,57 @@ def __init__(self, body: Any) -> None: super().__init__(HttpStatusCodeEnum.NOT_FOUND.value, body) +class MethodNotAllowed(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.METHOD_NOT_ALLOWED.value, body) + + +class NotAcceptable(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.NOT_ACCEPTABLE.value, body) + + class Conflict(HttpResponse): def __init__(self, body: Any) -> None: super().__init__(HttpStatusCodeEnum.CONFLICT.value, body) -class RedirectResponse(HttpResponse): - def __init__(self, body: dict) -> None: - super().__init__(HttpStatusCodeEnum.REDIRECT.value, None) - self.location = body +class UnsupportedMediaType(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.UNSUPPORTED_MEDIA_TYPE.value, body) -class Forbidden(HttpResponse): - def __init__(self, body: dict) -> None: - super().__init__(HttpStatusCodeEnum.FORBIDDEN.value, body) +class UnprocessableEntity(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.UNPROCESSABLE_ENTITY.value, body) + + +class TooManyRequests(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.TOO_MANY_REQUESTS.value, body) + + +# 5xx - Server errors +class InternalServerError(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.INTERNAL_SERVER_ERROR.value, body) + + +class NotImplementedResponse(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.NOT_IMPLEMENTED.value, body) + + +class BadGateway(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.BAD_GATEWAY.value, body) + + +class ServiceUnavailable(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.SERVICE_UNAVAILABLE.value, body) + + +class GatewayTimeout(HttpResponse): + def __init__(self, body: Any) -> None: + super().__init__(HttpStatusCodeEnum.GATEWAY_TIMEOUT.value, body) diff --git a/src/shared/helpers/external_interfaces/http_lambda_requests.py b/src/shared/helpers/external_interfaces/http_lambda_requests.py index 8d002ea..194bf23 100644 --- a/src/shared/helpers/external_interfaces/http_lambda_requests.py +++ b/src/shared/helpers/external_interfaces/http_lambda_requests.py @@ -1,5 +1,10 @@ import json +from typing import Any +from src.shared.helpers.auth.authorizer_user import ( + USER_FROM_AUTHORIZER_KEY, + parse_authorizer_user_from_event, +) from src.shared.helpers.external_interfaces.http_models import HttpRequest, HttpResponse @@ -9,26 +14,30 @@ class LambdaHttpResponse(HttpResponse): docs: https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html """ status_code: int = 200 - body: any = {"message": "No response"} + body: Any = {"message": "No response"} headers: dict = {"Content-Type": "application/json"} - def __init__(self, body: any = None, status_code: int = None, headers: dict = None, **kwargs) -> None: + def __init__( + self, + body: Any = None, + status_code: int = None, + headers: dict = None, + **kwargs + ) -> None: """ - Constructor for HttpResponse. + Constructor for LambdaHttpResponse. Args: body: The body of the response. Can be a string or a dict. status_code: The status code of the response. Defaults to 200. headers: The headers of the response. Defaults to {"Content-Type": "application/json"}. **kwargs: Configuration of the HTTP response. Possible values: add_default_cors_headers (default is True) """ - _body = body or LambdaHttpResponse.body - _headers = headers or LambdaHttpResponse.headers - _headers['Access-Control-Allow-Origin'] = '*' - - _status_code = status_code or LambdaHttpResponse.status_code + _body = LambdaHttpResponse.body if body is None else body + _headers = dict(headers) if headers is not None else dict(LambdaHttpResponse.headers) + _status_code = LambdaHttpResponse.status_code if status_code is None else status_code if kwargs.get("add_default_cors_headers", True): - _headers.update({"Access-Control-Allow-Origin": "*"}) + _headers["Access-Control-Allow-Origin"] = "*" super().__init__(body=_body, headers=_headers, status_code=_status_code) @@ -37,7 +46,7 @@ def toDict(self) -> dict: Returns a dict representation of the HttpResponse. Returns: { - 'statuCode': int + 'statusCode': int 'body': str or dict 'headers': dict 'isBase64Encoded': bool @@ -65,9 +74,9 @@ class LambdaDefaultHTTP: def __init__(self, data: dict = None) -> None: """ - Constructor for LambdaHttp. + Constructor for LambdaDefaultHTTP. Args: - event: dict - the event passed to the lambda function. + data: dict - the "http" section of the lambda event requestContext. """ if not data: return @@ -85,9 +94,9 @@ def __eq__(self, other): class LambdaHttpRequest(HttpRequest): """ - A class to represent an HTTP request for lambda URL. - docs: https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html - """ + A class to represent an HTTP request for lambda URL. + docs: https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html + """ version: str raw_path: str raw_query_string: str @@ -95,11 +104,13 @@ class LambdaHttpRequest(HttpRequest): query_string_parameters: dict request_context: dict http: LambdaDefaultHTTP - body: any + body: Any def __init__(self, data: dict = None) -> None: """ - Constructor for HttpResponse. + Constructor for LambdaHttpRequest. + Args: + data: dict - the event passed to the lambda function. """ _headers = data.get("headers") _query_string_parameters = data.get("queryStringParameters") @@ -118,10 +129,11 @@ def __init__(self, data: dict = None) -> None: self.raw_query_string = data.get("rawQueryString") self.query_string_parameters = data.get("queryStringParameters") self.request_context = data.get("requestContext") - self.http = LambdaDefaultHTTP(self.request_context.get("external_interfaces") if self.request_context else None) - - -class HttpResponseRedirect(HttpResponse): - - def __init__(self, location: str) -> None: - super().__init__(status_code=302, headers={"Location": location}) \ No newline at end of file + self.http = LambdaDefaultHTTP(self.request_context.get("http") if self.request_context else None) + + # injeta DEPOIS do body/query para o cliente não spoofar via payload + authorizer_user = parse_authorizer_user_from_event(data) + if authorizer_user is not None: + self.data[USER_FROM_AUTHORIZER_KEY] = authorizer_user + else: + self.data.pop(USER_FROM_AUTHORIZER_KEY, None) diff --git a/src/shared/helpers/observability/wrap_handler.py b/src/shared/helpers/observability/wrap_handler.py new file mode 100644 index 0000000..416c028 --- /dev/null +++ b/src/shared/helpers/observability/wrap_handler.py @@ -0,0 +1,51 @@ +""" +Observability invisível para quem escreve módulos. + +Novos devs só precisam disto no presenter: + + from src.shared.helpers.observability.wrap_handler import observed_handler + + @observed_handler("create_user") + def lambda_handler(event, context): + ... + +Controller / usecase / viewmodel não recebem observability. +Powertools (quando STAGE != TEST) fica encapsulado em ObservabilityAWS. +""" +from __future__ import annotations + +import time +from collections.abc import Callable +from functools import wraps +from typing import Any, TypeVar + +from src.shared.environments import Environments + +F = TypeVar("F", bound=Callable[..., Any]) + + +def observed_handler(module_name: str) -> Callable[[F], F]: + """Aplica logging / metrics / tracing sem poluir o código de negócio.""" + + def decorator(handler: F) -> F: + observability = Environments.get_observability()(module_name=module_name) + + @wraps(handler) + def inner(event: Any, context: Any) -> Any: + start = time.monotonic() + response = handler(event, context) + elapsed_ms = (time.monotonic() - start) * 1000 + observability.add_metric( + name="ProcessingTime", unit="Milliseconds", value=elapsed_ms + ) + if ( + isinstance(response, dict) + and response.get("statusCode") is not None + and response["statusCode"] >= 400 + ): + observability.add_metric(name="ErrorCount", unit="Count", value=1) + return response + + return observability.handler_decorators(inner) + + return decorator diff --git a/src/shared/infra/dto/member_dynamo_dto.py b/src/shared/infra/dto/member_dynamo_dto.py new file mode 100644 index 0000000..73f1a6f --- /dev/null +++ b/src/shared/infra/dto/member_dynamo_dto.py @@ -0,0 +1,35 @@ +from src.shared.domain.entities.member import Member +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + partition_key, + sort_key, + strip_keys, +) + + +class MemberDynamoDTO: + """ + DTO Dynamo: serializa entidade via model_dump + keys da single-table. + """ + + @staticmethod + def from_entity_to_dynamo(member: Member) -> dict: + """ + Converts a Member entity to a dictionary compatible with DynamoDB. + + Includes base keys (pk/sk). + """ + return { + **member.model_dump(mode="json"), + "pk": partition_key(kind=EntityKind.MEMBER), + "sk": sort_key(id=member.member_id, kind=EntityKind.MEMBER), + } + + @staticmethod + def from_dynamo_to_entity(member_data: dict) -> Member: + """ + Converts a DynamoDB item dict into a Member entity. + + Storage keys (pk/sk) are stripped before model_validate. + """ + return Member.model_validate(obj=strip_keys(member_data)) \ No newline at end of file diff --git a/src/shared/infra/dto/template_dynamo_dto.py b/src/shared/infra/dto/template_dynamo_dto.py new file mode 100644 index 0000000..ae2247d --- /dev/null +++ b/src/shared/infra/dto/template_dynamo_dto.py @@ -0,0 +1,35 @@ +from src.shared.domain.entities.user import User +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + build_gsi2_attributes, + partition_key, + sort_key, + strip_keys, +) + + +class UserDynamoDTO: + """Template DTO: serializa entidade via model_dump + keys da single-table.""" + + @staticmethod + def from_entity_to_dynamo(user: User) -> dict: + """ + Converts a User entity to a dictionary compatible with DynamoDB. + + Includes base keys (pk/sk) and GSI2 email attributes. + """ + return { + **user.model_dump(mode="json"), + "pk": partition_key(kind=EntityKind.USER), + "sk": sort_key(id=user.user_id, kind=EntityKind.USER), + **build_gsi2_attributes(user_email=user.email, user_id=user.user_id), + } + + @staticmethod + def from_dynamo_to_entity(user_data: dict) -> User: + """ + Converts a DynamoDB item dict into a User entity. + + Storage keys (pk/sk/gsi) are stripped before model_validate. + """ + return User.model_validate(obj=strip_keys(user_data)) diff --git a/src/shared/infra/dto/user_dynamo_dto.py b/src/shared/infra/dto/user_dynamo_dto.py deleted file mode 100644 index fee2c45..0000000 --- a/src/shared/infra/dto/user_dynamo_dto.py +++ /dev/null @@ -1,71 +0,0 @@ -from decimal import Decimal - -from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE - - -class UserDynamoDTO: - name: str - email: str - state: STATE - user_id: int - - def __init__(self, name: str, email: str, state: STATE, user_id: int): - self.name = name - self.email = email - self.user_id = user_id - self.state = state - - @staticmethod - def from_entity(user: User) -> "UserDynamoDTO": - """ - Parse data from User to UserDynamoDTO - """ - return UserDynamoDTO( - name=user.name, - email=user.email, - user_id=user.user_id, - state=user.state - ) - - def to_dynamo(self) -> dict: - """ - Parse data from UserDynamoDTO to dict - """ - return { - "entity": "user", - "name": self.name, - "email": self.email, - "user_id": Decimal(self.user_id), - "state": self.state.value - } - - @staticmethod - def from_dynamo(user_data: dict) -> "UserDynamoDTO": - """ - Parse data from DynamoDB to UserDynamoDTO - @param user_data: dict from DynamoDB - """ - return UserDynamoDTO( - name=user_data["name"], - email=user_data["email"], - user_id=int(user_data["user_id"]), - state=STATE(user_data["state"]) - ) - - def to_entity(self) -> User: - """ - Parse data from UserDynamoDTO to User - """ - return User( - name=self.name, - email=self.email, - user_id=self.user_id, - state=self.state - ) - - def __repr__(self): - return f"UserDynamoDto(name={self.name}, email={self.email}, user_id={self.user_id}, state={self.state})" - - def __eq__(self, other): - return self.__dict__ == other.__dict__ diff --git a/src/shared/infra/external/dynamo/dynamo_keys.py b/src/shared/infra/external/dynamo/dynamo_keys.py new file mode 100644 index 0000000..c4f3d25 --- /dev/null +++ b/src/shared/infra/external/dynamo/dynamo_keys.py @@ -0,0 +1,96 @@ +"""Convenções de chaves Dynamo (tabela base + GSI UserEmailIndex). + +Tabela base (single-table): + pk = USER | MEMBER | SUBSCRIBER + sk = USER# | MEMBER# | SUBSCRIBER# + +GSI2 (UserEmailIndex) — access pattern "user por email": + gsi2pk = EMAIL# + gsi2sk = USER# + +Uso no repository Dynamo (exemplo):: + + from boto3.dynamodb.conditions import Key + from src.shared.infra.external.dynamo.dynamo_keys import ( + GSI2_NAME, GSI2_PK_ATTR, gsi2_partition_key, + ) + + resp = self.dynamo.query( + KeyConditionExpression=Key(GSI2_PK_ATTR).eq(gsi2_partition_key(user_email)), + IndexName=GSI2_NAME, + ) +""" + +from enum import Enum +from typing import Any +from uuid import UUID + +from pydantic import EmailStr + +# Nomes dos atributos da tabela base (alinhados ao CDK: pk/sk) +PK_ATTR = "pk" +SK_ATTR = "sk" + +# GSI2 — access pattern: buscar user por email (denso: email sempre presente) +# Alinhado a iac/iac/template_dynamo_table.py (UserEmailIndex) +GSI2_NAME = "UserEmailIndex" +GSI2_PK_ATTR = "gsi2pk" +GSI2_SK_ATTR = "gsi2sk" + +STORAGE_KEY_ATTRS = ( + PK_ATTR, SK_ATTR, + GSI2_PK_ATTR, GSI2_SK_ATTR, +) + + +class EntityKind(str, Enum): + USER = "USER" + MEMBER = "MEMBER" + SUBSCRIBER = "SUBSCRIBER" + + +def partition_key(kind: EntityKind) -> str: + """PK da tabela base — coleção (se repete para todos os items do kind).""" + return kind.value + + +def sort_key(id: UUID, kind: EntityKind) -> str: + """SK da tabela base — identidade única dentro da coleção.""" + return f"{kind.value}#{id}" + + +def gsi2_partition_key(user_email: EmailStr) -> str: + """ + PK do GSI2 — agrupa por email. + + Ex.: EMAIL#user@example.com + """ + return f"EMAIL#{user_email}" + + +def gsi2_sort_key(user_id: UUID) -> str: + """ + SK do GSI2 — identidade do user no índice de email. + + Ex.: USER# + """ + return sort_key(id=user_id, kind=EntityKind.USER) + + +def build_gsi2_attributes( + user_email: EmailStr, + user_id: UUID, +) -> dict[str, str]: + """ + GSI denso: email é obrigatório na entidade User, então todo user + recebe gsi2pk/gsi2sk e entra no UserEmailIndex. + """ + return { + GSI2_PK_ATTR: gsi2_partition_key(user_email=user_email), + GSI2_SK_ATTR: gsi2_sort_key(user_id=user_id), + } + + +def strip_keys(item: dict[str, Any]) -> dict[str, Any]: + """Remove atributos de storage (pk/sk/gsi) antes do model_validate.""" + return {k: v for k, v in item.items() if k not in STORAGE_KEY_ATTRS} diff --git a/src/shared/infra/repositories/load_member_mock_to_dynamo.py b/src/shared/infra/repositories/load_member_mock_to_dynamo.py new file mode 100644 index 0000000..ab2d03f --- /dev/null +++ b/src/shared/infra/repositories/load_member_mock_to_dynamo.py @@ -0,0 +1,122 @@ +import argparse + +import boto3 +import dotenv + +from src.shared.environments import Environments, STAGE +from src.shared.helpers.errors.usecase_errors import DuplicatedMember +from src.shared.infra.external.dynamo.dynamo_keys import ( + GSI2_NAME, + GSI2_PK_ATTR, + GSI2_SK_ATTR, +) +from src.shared.infra.repositories.member_repository_dynamo import MemberRepositoryDynamo +from src.shared.infra.repositories.member_repository_mock import MemberRepositoryMock + + +def setup_dynamo_table(): + envs = Environments.get_envs() + table_name = envs.dynamo_table_name + endpoint_url = envs.dynamo_endpoint_url + pk = envs.dynamo_partition_key + sk = envs.dynamo_sort_key + + print("Setting up DynamoDB table...") + dynamo_client = boto3.client("dynamodb", endpoint_url=endpoint_url) + tables = dynamo_client.list_tables()["TableNames"] + + if table_name in tables: + print("Table already exists!") + return + + print("Creating table...") + dynamo_client.create_table( + TableName=table_name, + KeySchema=[ + {"AttributeName": pk, "KeyType": "HASH"}, + {"AttributeName": sk, "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": pk, "AttributeType": "S"}, + {"AttributeName": sk, "AttributeType": "S"}, + {"AttributeName": GSI2_PK_ATTR, "AttributeType": "S"}, + {"AttributeName": GSI2_SK_ATTR, "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": GSI2_NAME, + "KeySchema": [ + {"AttributeName": GSI2_PK_ATTR, "KeyType": "HASH"}, + {"AttributeName": GSI2_SK_ATTR, "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + BillingMode="PAY_PER_REQUEST", + ) + + print("Waiting for table to be created...") + dynamo_client.get_waiter("table_exists").wait(TableName=table_name) + print(f'Table "{table_name}" created!') + + +def _load_members(dynamo_repo: MemberRepositoryDynamo) -> int: + mock_repo = MemberRepositoryMock() + count = 0 + + print("Loading mock data to dynamo...") + for member in mock_repo.members: + print(f"Loading member {member.member_id} | {member.name} to dynamo") + try: + dynamo_repo.create_member(member) + count += 1 + except DuplicatedMember: + print(f" member {member.member_id} already exists, skipping") + + print(f"{count} members loaded to dynamo!") + return count + + +def load_mock_to_local_dynamo(): + """Create local table (if needed) and seed DynamoDB Local.""" + setup_dynamo_table() + _load_members(MemberRepositoryDynamo()) + + +def load_mock_to_real_dynamo(): + """ + Seed an already-deployed AWS table. + + Run manually after `cdk deploy` on DEV/HOMOLOG. + Does not create the table — CDK owns that. + Blocks PROD by default. + """ + envs = Environments.get_envs() + if envs.stage == STAGE.PROD: + raise RuntimeError( + "Refusing to seed PROD. Use DEV or HOMOLOG (or override intentionally)." + ) + + print( + f"Seeding AWS DynamoDB " + f"(stage={envs.stage.value}, table={envs.dynamo_table_name}, region={envs.region})" + ) + _load_members(MemberRepositoryDynamo()) + + +if __name__ == "__main__": + dotenv.load_dotenv() + + parser = argparse.ArgumentParser(description="Seed Member mock data into DynamoDB") + parser.add_argument( + "--target", + choices=["local", "aws"], + default="local", + help="local = DynamoDB Local (creates table). aws = deployed table (DEV/HOMOLOG only).", + ) + args = parser.parse_args() + + if args.target == "aws": + load_mock_to_real_dynamo() + else: + load_mock_to_local_dynamo() \ No newline at end of file diff --git a/src/shared/infra/repositories/load_user_mock_to_dynamo.py b/src/shared/infra/repositories/load_user_mock_to_dynamo.py index 8c55305..ee5f8cf 100644 --- a/src/shared/infra/repositories/load_user_mock_to_dynamo.py +++ b/src/shared/infra/repositories/load_user_mock_to_dynamo.py @@ -1,115 +1,122 @@ -from decimal import Decimal +import argparse import boto3 import dotenv -from src.shared.infra.repositories.user_repository_dynamo import UserRepositoryDynamo + +from src.shared.environments import Environments, STAGE +from src.shared.helpers.errors.usecase_errors import DuplicatedUser +from src.shared.infra.external.dynamo.dynamo_keys import ( + GSI2_NAME, + GSI2_PK_ATTR, + GSI2_SK_ATTR, +) +from src.shared.infra.repositories.template_repository_dynamo import TemplateRepositoryDynamo from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock -from src.shared.environments import Environments def setup_dynamo_table(): - dynamo_table_name = "user_mss_template-table" - endpoint_url = "http://localhost:8000" - print("Setting up DynamoDB table...") - - dynamo_client = boto3.client('dynamodb', endpoint_url=endpoint_url) - print("DynamoDB client created") - tables = dynamo_client.list_tables()['TableNames'] - - if dynamo_table_name not in tables: - print("Creating table...") - dynamo_client.create_table( - TableName=dynamo_table_name, - KeySchema=[ - { - 'AttributeName': 'PK', - 'KeyType': 'HASH' - }, - { - 'AttributeName': 'SK', - 'KeyType': 'RANGE' - } - ], - AttributeDefinitions=[ - { - 'AttributeName': 'PK', - 'AttributeType': 'S' - }, - { - 'AttributeName': 'SK', - 'AttributeType': 'S' - } - - ], - BillingMode='PAY_PER_REQUEST', - ) - print("Waiting for table to be created...") - dynamo_client.get_waiter('table_exists').wait(TableName=dynamo_table_name) - - print('Loading table...') - - dynamodb = boto3.resource('dynamodb', endpoint_url=endpoint_url) - - table = dynamodb.Table(dynamo_table_name) - - print("Adding counter to table") + envs = Environments.get_envs() + table_name = envs.dynamo_table_name + endpoint_url = envs.dynamo_endpoint_url + pk = envs.dynamo_partition_key + sk = envs.dynamo_sort_key - table.put_item( - Item={ - 'PK': 'COUNTER', - 'SK': 'COUNTER', - 'COUNTER': Decimal(0) - } - ) - - print('Table "user_mss_template-table" created!') + print("Setting up DynamoDB table...") + dynamo_client = boto3.client("dynamodb", endpoint_url=endpoint_url) + tables = dynamo_client.list_tables()["TableNames"] - else: + if table_name in tables: print("Table already exists!") + return + + print("Creating table...") + dynamo_client.create_table( + TableName=table_name, + KeySchema=[ + {"AttributeName": pk, "KeyType": "HASH"}, + {"AttributeName": sk, "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": pk, "AttributeType": "S"}, + {"AttributeName": sk, "AttributeType": "S"}, + {"AttributeName": GSI2_PK_ATTR, "AttributeType": "S"}, + {"AttributeName": GSI2_SK_ATTR, "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": GSI2_NAME, + "KeySchema": [ + {"AttributeName": GSI2_PK_ATTR, "KeyType": "HASH"}, + {"AttributeName": GSI2_SK_ATTR, "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + BillingMode="PAY_PER_REQUEST", + ) + print("Waiting for table to be created...") + dynamo_client.get_waiter("table_exists").wait(TableName=table_name) + print(f'Table "{table_name}" created!') -def load_mock_to_local_dynamo(): - setup_dynamo_table() - mock_repo = UserRepositoryMock() - dynamo_repo = UserRepositoryDynamo() +def _load_users(dynamo_repo: TemplateRepositoryDynamo) -> int: + mock_repo = UserRepositoryMock() count = 0 - print('Loading mock data to dynamo...') + print("Loading mock data to dynamo...") for user in mock_repo.users: - print(f"Loading user {user.user_id} | {user.name} to dynamo") - dynamo_repo.create_user(user) - count += 1 + print(f"Loading user {user.user_id} | {user.email} to dynamo") + try: + dynamo_repo.create_user(user) + count += 1 + except DuplicatedUser: + print(f" user {user.user_id} already exists, skipping") print(f"{count} users loaded to dynamo!") + return count -def load_mock_to_real_dynamo(): - mock_repo = UserRepositoryMock() - dynamo_repo = UserRepositoryDynamo() - count = 0 +def load_mock_to_local_dynamo(): + """Create local table (if needed) and seed DynamoDB Local.""" + setup_dynamo_table() + _load_users(TemplateRepositoryDynamo()) - dynamodb = boto3.resource('dynamodb') - table = dynamodb.Table(dynamo_table_name=Environments.get_envs().dynamo_table_name) - print("Adding counter to table") +def load_mock_to_real_dynamo(): + """ + Seed an already-deployed AWS table. + + Run manually after `cdk deploy` on DEV/HOMOLOG. + Does not create the table — CDK owns that. + Blocks PROD by default. + """ + envs = Environments.get_envs() + if envs.stage == STAGE.PROD: + raise RuntimeError( + "Refusing to seed PROD. Use DEV or HOMOLOG (or override intentionally)." + ) - table.put_item( - Item={ - 'PK': 'COUNTER', - 'SK': 'COUNTER', - 'COUNTER': Decimal(0) - } + print( + f"Seeding AWS DynamoDB " + f"(stage={envs.stage.value}, table={envs.dynamo_table_name}, region={envs.region})" ) + _load_users(TemplateRepositoryDynamo()) - print('Loading mock data to dynamo...') - for user in mock_repo.users: - print(f"Loading user {user.user_id} | {user.name} to dynamo") - dynamo_repo.create_user(user) - count += 1 - print(f"{count} users loaded to dynamo!") - -if __name__ == '__main__': +if __name__ == "__main__": dotenv.load_dotenv() - load_mock_to_local_dynamo() + + parser = argparse.ArgumentParser(description="Seed User mock data into DynamoDB") + parser.add_argument( + "--target", + choices=["local", "aws"], + default="local", + help="local = DynamoDB Local (creates table). aws = deployed table (DEV/HOMOLOG only).", + ) + args = parser.parse_args() + + if args.target == "aws": + load_mock_to_real_dynamo() + else: + load_mock_to_local_dynamo() diff --git a/src/shared/infra/repositories/member_repository_dynamo.py b/src/shared/infra/repositories/member_repository_dynamo.py new file mode 100644 index 0000000..9508492 --- /dev/null +++ b/src/shared/infra/repositories/member_repository_dynamo.py @@ -0,0 +1,99 @@ +from typing import List +from uuid import UUID + +from boto3.dynamodb.conditions import Key + +from src.shared.domain.entities.member import Member +from src.shared.domain.repositories.member_repository_interface import IMemberRepository +from src.shared.environments import Environments +from src.shared.helpers.errors.usecase_errors import DuplicatedMember, NoItemsFound +from src.shared.infra.dto.member_dynamo_dto import MemberDynamoDTO +from src.shared.infra.external.dynamo.datasources.dynamo_datasource import DynamoDatasource +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + PK_ATTR, + partition_key, + sort_key, +) + + +class MemberRepositoryDynamo(IMemberRepository): + """Repositório Dynamo (single-table) para a entidade Member.""" + + def __init__(self): + envs = Environments.get_envs() + self.dynamo = DynamoDatasource( + dynamo_table_name=envs.dynamo_table_name, + region=envs.region, + partition_key=envs.dynamo_partition_key, + sort_key=envs.dynamo_sort_key, + endpoint_url=envs.dynamo_endpoint_url, + ) + + def _pk(self) -> str: + return partition_key(kind=EntityKind.MEMBER) + + def _sk(self, member_id: UUID) -> str: + return sort_key(id=member_id, kind=EntityKind.MEMBER) + + def get_member(self, member_id: UUID) -> Member: + resp = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(member_id), + ) + + if resp.get("Item") is None: + raise NoItemsFound("member_id") + + return MemberDynamoDTO.from_dynamo_to_entity(resp["Item"]) + + def get_all_member(self) -> List[Member]: + resp = self.dynamo.query( + key_condition_expression=Key(PK_ATTR).eq(self._pk()), + ) + + return [ + MemberDynamoDTO.from_dynamo_to_entity(member) + for member in resp.get("Items", []) + ] + + def create_member(self, new_member: Member) -> Member: + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(new_member.member_id), + ) + if existing.get("Item") is not None: + raise DuplicatedMember("member_id") + + self.dynamo.put_item( + item=MemberDynamoDTO.from_entity_to_dynamo(new_member), + partition_key=self._pk(), + sort_key=self._sk(new_member.member_id), + ) + return new_member + + def delete_member(self, member_id: UUID) -> Member: + resp = self.dynamo.delete_item( + partition_key=self._pk(), + sort_key=self._sk(member_id), + ) + + if "Attributes" not in resp: + raise NoItemsFound("member_id") + + return MemberDynamoDTO.from_dynamo_to_entity(resp["Attributes"]) + + def update_member(self, member: Member) -> Member: + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(member.member_id), + ) + if existing.get("Item") is None: + raise NoItemsFound("member_id") + + self.dynamo.put_item( + item=MemberDynamoDTO.from_entity_to_dynamo(member), + partition_key=self._pk(), + sort_key=self._sk(member.member_id), + ) + return member \ No newline at end of file diff --git a/src/shared/infra/repositories/member_repository_mock.py b/src/shared/infra/repositories/member_repository_mock.py new file mode 100644 index 0000000..6447fc6 --- /dev/null +++ b/src/shared/infra/repositories/member_repository_mock.py @@ -0,0 +1,58 @@ +import uuid +from typing import List + +from src.shared.domain.entities.member import Member +from src.shared.domain.repositories.member_repository_interface import IMemberRepository +from src.shared.helpers.errors.usecase_errors import NoItemsFound + + +class MemberRepositoryMock(IMemberRepository): + members: List[Member] + + def __init__(self): + self.members = [ + Member( + name="Nome do Membro Um", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/1", + photo="https://portalinterno.devmaua.com/assets/logo_fake1.png", + description="Exemplo de descrição do membro 1" + ), + Member( + name="Nome do Membro Dois", + function="Redacao", + status="Congelado", + linkedin="https://www.linkedin.com/2", + photo="https://portalinterno.devmaua.com/assets/logo_fake2.png", + description="Exemplo de descrição do membro 2" + ) + ] + + def get_member(self, member_id: uuid.UUID) -> Member: + for member in self.members: + if member.member_id == member_id: + return member + raise NoItemsFound("member_id") + + def get_all_member(self) -> List[Member]: + return self.members + + def create_member(self, new_member: Member) -> Member: + self.members.append(new_member) + return new_member + + def delete_member(self, member_id: uuid.UUID) -> Member: + for idx, member in enumerate(self.members): + if member.member_id == member_id: + return self.members.pop(idx) + + raise NoItemsFound("member_id") + + def update_member(self, member: Member) -> Member: + for idx, stored_member in enumerate(self.members): + if stored_member.member_id == member.member_id: + self.members[idx] = member + return self.members[idx] + + raise NoItemsFound("member_id") diff --git a/src/shared/infra/repositories/template_repository_dynamo.py b/src/shared/infra/repositories/template_repository_dynamo.py new file mode 100644 index 0000000..5b67e49 --- /dev/null +++ b/src/shared/infra/repositories/template_repository_dynamo.py @@ -0,0 +1,112 @@ +from typing import List +from uuid import UUID + +from boto3.dynamodb.conditions import Key + +from src.shared.domain.entities.user import User +from src.shared.domain.repositories.user_repository_interface import IUserRepository +from src.shared.environments import Environments +from src.shared.helpers.errors.usecase_errors import DuplicatedUser, NoItemsFound +from src.shared.infra.dto.template_dynamo_dto import UserDynamoDTO +from src.shared.infra.external.dynamo.datasources.dynamo_datasource import DynamoDatasource +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + GSI2_NAME, + GSI2_PK_ATTR, + PK_ATTR, + gsi2_partition_key, + partition_key, + sort_key, +) + + +class TemplateRepositoryDynamo(IUserRepository): + """Template de repositório Dynamo (single-table) para a entidade User.""" + + def __init__(self): + envs = Environments.get_envs() + self.dynamo = DynamoDatasource( + dynamo_table_name=envs.dynamo_table_name, + region=envs.region, + partition_key=envs.dynamo_partition_key, + sort_key=envs.dynamo_sort_key, + endpoint_url=envs.dynamo_endpoint_url, + ) + + def _pk(self) -> str: + return partition_key(kind=EntityKind.USER) + + def _sk(self, user_id: UUID) -> str: + return sort_key(id=user_id, kind=EntityKind.USER) + + def get_user(self, user_id: UUID) -> User: + resp = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(user_id), + ) + + if resp.get("Item") is None: + raise NoItemsFound("user_id") + + return UserDynamoDTO.from_dynamo_to_entity(resp["Item"]) + + def get_all_user(self) -> List[User]: + resp = self.dynamo.query( + key_condition_expression=Key(PK_ATTR).eq(self._pk()), + ) + + return [ + UserDynamoDTO.from_dynamo_to_entity(user) + for user in resp.get("Items", []) + ] + + def create_user(self, new_user: User) -> User: + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(new_user.user_id), + ) + if existing.get("Item") is not None: + raise DuplicatedUser("user_id") + + email_resp = self.dynamo.query( + key_condition_expression=Key(GSI2_PK_ATTR).eq( + gsi2_partition_key(new_user.email) + ), + IndexName=GSI2_NAME, + Select="COUNT", + ) + if email_resp.get("Count", 0) > 0: + raise DuplicatedUser("email") + + self.dynamo.put_item( + item=UserDynamoDTO.from_entity_to_dynamo(new_user), + partition_key=self._pk(), + sort_key=self._sk(new_user.user_id), + ) + return new_user + + def delete_user(self, user_id: UUID) -> User: + resp = self.dynamo.delete_item( + partition_key=self._pk(), + sort_key=self._sk(user_id), + ) + + if "Attributes" not in resp: + raise NoItemsFound("user_id") + + return UserDynamoDTO.from_dynamo_to_entity(resp["Attributes"]) + + def update_user(self, user: User) -> User: + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(user.user_id), + ) + if existing.get("Item") is None: + raise NoItemsFound("user_id") + + self.dynamo.put_item( + item=UserDynamoDTO.from_entity_to_dynamo(user), + partition_key=self._pk(), + sort_key=self._sk(user.user_id), + ) + return user diff --git a/src/shared/infra/repositories/user_repository_dynamo.py b/src/shared/infra/repositories/user_repository_dynamo.py index 15accd9..995485e 100644 --- a/src/shared/infra/repositories/user_repository_dynamo.py +++ b/src/shared/infra/repositories/user_repository_dynamo.py @@ -1,92 +1,106 @@ -from decimal import Decimal from typing import List +from uuid import UUID from src.shared.domain.entities.user import User from src.shared.domain.repositories.user_repository_interface import IUserRepository +from src.shared.helpers.errors.usecase_errors import DuplicatedUser, NoItemsFound + +from boto3.dynamodb.conditions import Key + from src.shared.environments import Environments -from src.shared.helpers.errors.usecase_errors import NoItemsFound -from src.shared.infra.dto.user_dynamo_dto import UserDynamoDTO +from ..dto.template_dynamo_dto import UserDynamoDTO from src.shared.infra.external.dynamo.datasources.dynamo_datasource import DynamoDatasource +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + GSI2_NAME, + GSI2_PK_ATTR, + PK_ATTR, + gsi2_partition_key, + partition_key, + sort_key, +) class UserRepositoryDynamo(IUserRepository): - @staticmethod - def partition_key_format(user_id) -> str: - return f"user#{user_id}" - - @staticmethod - def sort_key_format(user_id: int) -> str: - return f"#{user_id}" - - def __init__(self): - self.dynamo = DynamoDatasource(endpoint_url=Environments.get_envs().endpoint_url, - dynamo_table_name=Environments.get_envs().dynamo_table_name, - region=Environments.get_envs().region, - partition_key=Environments.get_envs().dynamo_partition_key, - sort_key=Environments.get_envs().dynamo_sort_key) - def get_user(self, user_id: int) -> User: - resp = self.dynamo.get_item(partition_key=self.partition_key_format(user_id), sort_key=self.sort_key_format(user_id)) - - if resp.get('Item') is None: + def __init__(self) -> None: + envs = Environments.get_envs() + self.dynamo = DynamoDatasource( + dynamo_table_name=envs.dynamo_table_name, + region=envs.region, + partition_key=envs.dynamo_partition_key, + sort_key=envs.dynamo_sort_key, + endpoint_url=envs.dynamo_endpoint_url, + ) + + def _pk(self) -> str: + return partition_key(kind=EntityKind.USER) + + def _sk(self, user_id: UUID) -> str: + return sort_key(id=user_id, kind=EntityKind.USER) + + def get_user(self, user_id: UUID) -> User: + resp = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(user_id), + ) + + if resp.get("Item") is None: raise NoItemsFound("user_id") - user_dto = UserDynamoDTO.from_dynamo(resp["Item"]) - return user_dto.to_entity() - + return UserDynamoDTO.from_dynamo_to_entity(resp["Item"]) + def get_all_user(self) -> List[User]: - resp = self.dynamo.get_all_items() - users = [] - for item in resp['Items']: - if item.get("entity") == 'user': - users.append(UserDynamoDTO.from_dynamo(item).to_entity()) + resp = self.dynamo.query( + key_condition_expression=Key(PK_ATTR).eq(self._pk()) + ) - return users + return [ + UserDynamoDTO.from_dynamo_to_entity(user) + for user in resp.get("Items", []) + ] def create_user(self, new_user: User) -> User: - print(f"repo entered.\n Repo:{self}") - print(self.dynamo.dynamo_table.__dict__) - new_user.user_id = self.get_user_counter() - print(f"nre user id: {new_user.user_id}") - user_dto = UserDynamoDTO.from_entity(user=new_user) - resp = self.dynamo.put_item(partition_key=self.partition_key_format(new_user.user_id), - sort_key=self.sort_key_format(user_id=new_user.user_id), item=user_dto.to_dynamo(), - is_decimal=True) - return new_user + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(new_user.user_id), + ) - def delete_user(self, user_id: int) -> User: - resp = self.dynamo.delete_item(partition_key=self.partition_key_format(user_id), sort_key=self.sort_key_format(user_id)) + if existing.get("Item") is not None: + DuplicatedUser("user_id") - if "Attributes" not in resp: - raise NoItemsFound("user_id") + self.dynamo.put_item( + item=UserDynamoDTO.from_entity_to_dynamo(new_user), + partition_key=self._pk(), + sort_key=self._sk(new_user.user_id), + ) - return UserDynamoDTO.from_dynamo(resp['Attributes']).to_entity() - - def update_user(self, user_id: int, new_name: str) -> User: - - user = self.get_user(user_id=user_id) - - item_to_update = {} - - if new_name: - item_to_update['name'] = new_name - else: - raise NoItemsFound("Nothing to update") + return new_user - resp = self.dynamo.update_item(partition_key=self.partition_key_format(user_id), sort_key=self.sort_key_format(user_id), update_dict=item_to_update) + def delete_user(self, user_id: UUID) -> User: + resp = self.dynamo.delete_item( + partition_key=self._pk(), + sort_key=self._sk(user_id), + ) - return UserDynamoDTO.from_dynamo(resp['Attributes']).to_entity() + if "Attributes" not in resp: + raise NoItemsFound("user_id") - def get_user_counter(self) -> int: + return UserDynamoDTO.from_dynamo_to_entity(resp["Attributes"]) - return self.update_counter() - def update_counter(self) -> int: #TODO fix this - print("updating counter") - counter = int(self.dynamo.get_item(partition_key='COUNTER', sort_key='COUNTER')['Item']['COUNTER']) - print(f"counter: {counter}") - resp = self.dynamo.update_item(partition_key='COUNTER', sort_key='COUNTER', update_dict={'COUNTER': Decimal(counter+1)}) - print(f"resp: {resp}") + def update_user(self, user: User) -> User: + existing = self.dynamo.get_item( + partition_key=self._pk(), + sort_key=self._sk(user.user_id), + ) + if existing.get("Item") is None: + raise NoItemsFound("user_id") - return int(resp['Attributes']['COUNTER']) + self.dynamo.put_item( + item=UserDynamoDTO.from_entity_to_dynamo(user), + partition_key=self._pk(), + sort_key=self._sk(user.user_id), + ) + return user \ No newline at end of file diff --git a/src/shared/infra/repositories/user_repository_mock.py b/src/shared/infra/repositories/user_repository_mock.py index d576853..e36055c 100644 --- a/src/shared/infra/repositories/user_repository_mock.py +++ b/src/shared/infra/repositories/user_repository_mock.py @@ -1,24 +1,22 @@ +import uuid from typing import List from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.helpers.errors.usecase_errors import NoItemsFound class UserRepositoryMock(IUserRepository): users: List[User] - user_counter: int def __init__(self): self.users = [ - User(name="Bruno Soller", email="soller@soller.com", user_id=1, state=STATE.APPROVED), - User(name="Vitor Brancas", email="brancas@brancas.com", user_id=2, state=STATE.REJECTED), - User(name="João Vilas", email="bruno@bruno.com", user_id=3, state=STATE.PENDING) + User(user_id=uuid.UUID('5b20bcf8-f467-4569-83f2-1744534c162a'), email="admin@example.com", role=ROLE.ADMIN), + User(user_id=uuid.UUID('842faa44-caf7-43bd-8019-d5ae5d3942b2'),email="user@example.com", role=ROLE.USER), ] - self.user_counter = 3 - def get_user(self, user_id: int) -> User: + def get_user(self, user_id: uuid.UUID) -> User: for user in self.users: if user.user_id == user_id: return user @@ -29,23 +27,19 @@ def get_all_user(self) -> List[User]: def create_user(self, new_user: User) -> User: self.users.append(new_user) - self.user_counter += 1 return new_user - def delete_user(self, user_id: int) -> User: + def delete_user(self, user_id: uuid.UUID) -> User: for idx, user in enumerate(self.users): if user.user_id == user_id: return self.users.pop(idx) raise NoItemsFound("user_id") - def update_user(self, user_id: int, new_name: str) -> User: - for user in self.users: - if user.user_id == user_id: - user.name = new_name - return user - - raise NoItemsFound("user_id") + def update_user(self, user: User) -> User: + for idx, stored_user in enumerate(self.users): + if stored_user.user_id == user.user_id: + self.users[idx] = user + return self.users[idx] - def get_user_counter(self) -> int: - return self.user_counter + raise NoItemsFound("user_id") \ No newline at end of file diff --git a/tests/modules/create_user/app/test_create_user_controller.py b/tests/modules/create_user/app/test_create_user_controller.py index d34ff44..873b7e0 100644 --- a/tests/modules/create_user/app/test_create_user_controller.py +++ b/tests/modules/create_user/app/test_create_user_controller.py @@ -11,40 +11,21 @@ def test_create_user_controller(self): controller = CreateUserController(usecase=usecase) request = HttpRequest(body={ - 'name': 'Branco do Branco Branco da Silva', 'email': 'branco@branco.com' }) response = controller(request=request) assert response.status_code == 201 - assert response.body['user_id'] == repo.users[-1].user_id - assert response.body['name'] == repo.users[-1].name assert response.body['email'] == repo.users[-1].email - assert response.body['state'] == repo.users[-1].state.value assert response.body['message'] == "the user was created successfully" - def test_create_user_controller_missing_name(self): - repo = UserRepositoryMock() - usecase = CreateUserUsecase(repo=repo) - controller = CreateUserController(usecase=usecase) - - request = HttpRequest(body={ - 'email': '21.01444-2@maua.br'}) - - response = controller(request=request) - - assert response.status_code == 400 - assert response.body == "Field name is missing" - - def test_create_user_controller_missing_email(self): repo = UserRepositoryMock() usecase = CreateUserUsecase(repo=repo) controller = CreateUserController(usecase=usecase) - request = HttpRequest(body={ - 'name': 'Branco do Branco Branco da Silva'}) + request = HttpRequest(body={}) response = controller(request=request) @@ -57,29 +38,10 @@ def test_create_user_controller_invalid_email(self): controller = CreateUserController(usecase=usecase) request = HttpRequest(body={ - 'name': 'Branco do Branco Branco da Silva', - 'email': 'branco@branco'}) + 'email': 'branco@branco' + }) response = controller(request=request) assert response.status_code == 400 assert response.body == "Field email is not valid" - - def test_create_user_controller_invalid_name(self): - repo = UserRepositoryMock() - usecase = CreateUserUsecase(repo=repo) - controller = CreateUserController(usecase=usecase) - - request = HttpRequest(body={ - 'name': 'B', - 'email': 'branco@branco.com'}) - - response = controller(request=request) - - assert response.status_code == 400 - assert response.body == "Field name is not valid" - - - - - diff --git a/tests/modules/create_user/app/test_create_user_presenter.py b/tests/modules/create_user/app/test_create_user_presenter.py index 4b8eaea..bd38fb7 100644 --- a/tests/modules/create_user/app/test_create_user_presenter.py +++ b/tests/modules/create_user/app/test_create_user_presenter.py @@ -52,7 +52,7 @@ def test_create_user(self): "time": "12/Mar/2020:19:03:58 +0000", "timeEpoch": 1583348638390 }, - "body": '{"name":"EhOLudjas", "email":"eho@ludjas.com"}', + "body": '{"email":"eho@ludjas.com"}', "pathParameters": None, "isBase64Encoded": None, "stageVariables": None diff --git a/tests/modules/create_user/app/test_create_user_usecase.py b/tests/modules/create_user/app/test_create_user_usecase.py index 8ca54b9..007e8fc 100644 --- a/tests/modules/create_user/app/test_create_user_usecase.py +++ b/tests/modules/create_user/app/test_create_user_usecase.py @@ -11,22 +11,13 @@ def test_create_user(self): repo = UserRepositoryMock() usecase = CreateUserUsecase(repo) - user = usecase(name="Vitor Choueri", email="branco@branco.branco") + user = usecase(email="branco@branco.branco") assert repo.users[-1] == user - def test_create_user_invalid_name(self): - repo = UserRepositoryMock() - usecase = CreateUserUsecase(repo) - - with pytest.raises(EntityError): - user = usecase(name="V", email="branco@branco.branco") - def test_create_user_invalid_email(self): repo = UserRepositoryMock() usecase = CreateUserUsecase(repo) with pytest.raises(EntityError): - user = usecase(name="Vitor Choueri", email="branco@brancobranco") - - + user = usecase( email="branco@brancobranco") \ No newline at end of file diff --git a/tests/modules/create_user/app/test_create_user_viewmodel.py b/tests/modules/create_user/app/test_create_user_viewmodel.py index 2437cc2..37905bd 100644 --- a/tests/modules/create_user/app/test_create_user_viewmodel.py +++ b/tests/modules/create_user/app/test_create_user_viewmodel.py @@ -1,22 +1,22 @@ +from uuid import uuid4 from src.modules.create_user.app.create_user_viewmodel import CreateUserViewmodel from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE class Test_CreateUserViewModel: def test_create_user_viewmodel(self): + user_id = uuid4() user = User( - user_id=1, - name="Vitor Soller", + user_id=user_id, email="vitinho@hype.com", - state=STATE.APPROVED + role=ROLE.ADMIN ) userViewmodel = CreateUserViewmodel(user=user).to_dict() - expected = {'user_id': 1, - 'name': 'Vitor Soller', + expected = {'user_id': str(user_id), 'email': 'vitinho@hype.com', - 'state': 'APPROVED', + 'role': 'admin', 'message': 'the user was created successfully'} - assert expected == userViewmodel + assert expected == userViewmodel \ No newline at end of file diff --git a/tests/modules/delete_user/app/test_delete_user_controller.py b/tests/modules/delete_user/app/test_delete_user_controller.py index 78ea011..4ccaf79 100644 --- a/tests/modules/delete_user/app/test_delete_user_controller.py +++ b/tests/modules/delete_user/app/test_delete_user_controller.py @@ -1,3 +1,4 @@ +import uuid from src.modules.delete_user.app.delete_user_controller import DeleteUserController from src.modules.delete_user.app.delete_user_usecase import DeleteUserUsecase from src.shared.helpers.external_interfaces.http_models import HttpRequest @@ -10,9 +11,11 @@ def test_delete_user_controller(self): usecase = DeleteUserUsecase(repo=repo) controller = DeleteUserController(usecase=usecase) - request = HttpRequest(body={ - 'user_id': '1' - }) + request = HttpRequest( + body={'user_id': str(repo.users[0].user_id)} + ) + + print(request.body) response = controller(request=request) @@ -59,7 +62,7 @@ def test_delete_user_controller_invalid_user_id(self): response = controller(request=request) assert response.status_code == 400 - assert response.body == "Field user_id isn't in the right type.\n Received: int.\n Expected: str" + assert response.body == "The field 'user_id' has the wrong type. Received: 'int'. Expected: 'str'." def test_delete_user_controller_no_items_found(self): repo = UserRepositoryMock() @@ -67,12 +70,10 @@ def test_delete_user_controller_no_items_found(self): controller = DeleteUserController(usecase=usecase) request = HttpRequest(body={ - 'user_id': '69' + 'user_id': str(uuid.uuid4()) }) response = controller(request=request) assert response.status_code == 404 - assert response.body == 'No items found for user_id' - - + assert response.body == 'No items found for user_id' \ No newline at end of file diff --git a/tests/modules/delete_user/app/test_delete_user_presenter.py b/tests/modules/delete_user/app/test_delete_user_presenter.py index 9167bca..007271b 100644 --- a/tests/modules/delete_user/app/test_delete_user_presenter.py +++ b/tests/modules/delete_user/app/test_delete_user_presenter.py @@ -1,11 +1,12 @@ import json from src.modules.delete_user.app.delete_user_presenter import lambda_handler - +from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock class Test_DeleteUserPresenter: def test_delete_user(self): + repo = UserRepositoryMock() event = { "version": "2.0", "routeKey": "$default", @@ -52,7 +53,7 @@ def test_delete_user(self): "time": "12/Mar/2020:19:03:58 +0000", "timeEpoch": 1583348638390 }, - "body": '{"user_id": "1"}', + "body": '{"user_id": "5b20bcf8-f467-4569-83f2-1744534c162a"}', "pathParameters": None, "isBase64Encoded": None, "stageVariables": None @@ -60,11 +61,12 @@ def test_delete_user(self): response = lambda_handler(event, None) - expected = {'user_id': 1, - 'name': 'Bruno Soller', - 'email': 'soller@soller.com', - 'state': 'APPROVED', - 'message': 'the user was deleted successfully'} + expected = { + 'user_id': '5b20bcf8-f467-4569-83f2-1744534c162a', + 'email': 'admin@example.com', + 'role': 'admin', + 'message': 'the user was deleted successfully' + } assert json.loads(response["body"]) == expected assert response["statusCode"] == 200 diff --git a/tests/modules/delete_user/app/test_delete_user_usecase.py b/tests/modules/delete_user/app/test_delete_user_usecase.py index e7ce5b7..c21da51 100644 --- a/tests/modules/delete_user/app/test_delete_user_usecase.py +++ b/tests/modules/delete_user/app/test_delete_user_usecase.py @@ -1,3 +1,4 @@ +import uuid import pytest from src.modules.delete_user.app.delete_user_usecase import DeleteUserUsecase @@ -13,7 +14,7 @@ def test_delete_user(self): lenBefore = len(repo.users) - user = usecase(1) + user = usecase(uuid.UUID('5b20bcf8-f467-4569-83f2-1744534c162a')) assert len(repo.users) == lenBefore - 1 @@ -22,11 +23,4 @@ def test_delete_user_not_found(self): usecase = DeleteUserUsecase(repo) with pytest.raises(NoItemsFound): - user = usecase(69) - - def test_delete_user_invalid_id(self): - repo = UserRepositoryMock() - usecase = DeleteUserUsecase(repo) - - with pytest.raises(EntityError): - user = usecase("invalid") + user = usecase(uuid.uuid4()) diff --git a/tests/modules/delete_user/app/test_delete_user_viewmodel.py b/tests/modules/delete_user/app/test_delete_user_viewmodel.py index 5a3d38c..a3c19f2 100644 --- a/tests/modules/delete_user/app/test_delete_user_viewmodel.py +++ b/tests/modules/delete_user/app/test_delete_user_viewmodel.py @@ -1,23 +1,24 @@ +import uuid from src.modules.delete_user.app.delete_user_viewmodel import DeleteUserViewmodel from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE class Test_DeleteUserViewmodel: def test_delete_user_viewmodel(self): + user_id = uuid.uuid4() user = User( - user_id=1, - name="Vitinho da Silva", + user_id = user_id, email="21.01444-2@maua.br", - state=STATE.APPROVED) + role=ROLE.ADMIN + ) delete_user_viewmodel = DeleteUserViewmodel(user) expected = { - 'user_id': 1, - 'name': 'Vitinho da Silva', + 'user_id': str(user_id), 'email': '21.01444-2@maua.br', - 'state': 'APPROVED', + 'role': 'admin', 'message': 'the user was deleted successfully'} - assert expected == delete_user_viewmodel.to_dict() + assert expected == delete_user_viewmodel.to_dict() \ No newline at end of file diff --git a/tests/modules/get_all_users/app/test_get_all_users_presenter.py b/tests/modules/get_all_users/app/test_get_all_users_presenter.py index 63f4ddd..aae6275 100644 --- a/tests/modules/get_all_users/app/test_get_all_users_presenter.py +++ b/tests/modules/get_all_users/app/test_get_all_users_presenter.py @@ -60,23 +60,15 @@ def test_get_all_users_presenter(self): expected_body = { "all_users": [ { - 'user_id': 1, - 'name': "Bruno Soller", - 'email': "soller@soller.com", - 'state': 'APPROVED', + 'user_id': '5b20bcf8-f467-4569-83f2-1744534c162a', + 'email': "admin@example.com", + 'role': 'admin', }, { - 'user_id': 2, - 'name': "Vitor Brancas", - 'email': "brancas@brancas.com", - 'state': 'REJECTED', + 'user_id': '842faa44-caf7-43bd-8019-d5ae5d3942b2', + 'email': "user@example.com", + 'role': 'user', }, - { - 'user_id': 3, - 'name': "João Vilas", - 'email': "bruno@bruno.com", - 'state': 'PENDING', - } ], "message": "all users has been retrieved" } diff --git a/tests/modules/get_all_users/app/test_get_all_users_viewmodel.py b/tests/modules/get_all_users/app/test_get_all_users_viewmodel.py index f9b26ba..b8125e8 100644 --- a/tests/modules/get_all_users/app/test_get_all_users_viewmodel.py +++ b/tests/modules/get_all_users/app/test_get_all_users_viewmodel.py @@ -1,19 +1,16 @@ from src.modules.get_all_users.app.get_all_users_viewmodel import GetAllUsersViewmodel, UserViewmodel from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE - +from src.shared.domain.enums.role_enum import ROLE class Test_GetAllUsersViewmodel: all_users_list = [ - User(user_id=1, - name="Lucas Duez", + User(user_id='842faa44-caf7-43bd-8019-d5ae5d3942b2', email="deuzexmachina@gmail.com", - state=STATE.APPROVED), + role=ROLE.ADMIN), - User(user_id=2, - name="Laura Blablachan", + User(user_id='5b20bcf8-f467-4569-83f2-1744534c162a', email="laurinha@gmail.com", - state=STATE.APPROVED), + role=ROLE.USER), ] def test_get_all_users_viewmodel(self): @@ -22,16 +19,14 @@ def test_get_all_users_viewmodel(self): expected = { "all_users": [ { - 'user_id': 1, - 'name': "Lucas Duez", + 'user_id': '842faa44-caf7-43bd-8019-d5ae5d3942b2', 'email': "deuzexmachina@gmail.com", - 'state': 'APPROVED', + 'role': 'admin', }, { - 'user_id': 2, - 'name': "Laura Blablachan", + 'user_id': '5b20bcf8-f467-4569-83f2-1744534c162a', 'email': "laurinha@gmail.com", - 'state': 'APPROVED', + 'role': 'user', } ], "message": "all users has been retrieved" @@ -43,22 +38,19 @@ def test_get_all_users_viewmodel(self): def test_user_viewmodel(self): viewmodel = UserViewmodel( - User(user_id=2, - name="Laura Blablachan", + User(user_id='5b20bcf8-f467-4569-83f2-1744534c162a', email="laurinha@gmail.com", - state=STATE.APPROVED), + role=ROLE.USER), ) response = viewmodel.to_dict() - expected = { - 'user_id': 2, - 'name': "Laura Blablachan", - 'email': "laurinha@gmail.com", - 'state': 'APPROVED', - } + expected ={ + 'user_id': '5b20bcf8-f467-4569-83f2-1744534c162a', + 'email': "laurinha@gmail.com", + 'role': 'user', + } assert response == expected - diff --git a/tests/modules/get_user/app/test_get_user_controller.py b/tests/modules/get_user/app/test_get_user_controller.py index 4747fd1..a99f18f 100644 --- a/tests/modules/get_user/app/test_get_user_controller.py +++ b/tests/modules/get_user/app/test_get_user_controller.py @@ -1,3 +1,4 @@ +import uuid from src.modules.get_user.app.get_user_controller import GetUserController from src.modules.get_user.app.get_user_usecase import GetUserUsecase from src.shared.helpers.external_interfaces.http_models import HttpRequest @@ -19,10 +20,9 @@ def test_get_user_controller(self): response = controller(request=request) assert response.status_code == 200 - assert response.body['user_id'] == repo.users[1].user_id - assert response.body['name'] == repo.users[1].name + assert response.body['user_id'] == str(repo.users[1].user_id) assert response.body['email'] == repo.users[1].email - assert response.body['state'] == repo.users[1].state.value + assert response.body['role'] == repo.users[1].role def test_get_user_controller_missing_parameters(self): repo = UserRepositoryMock() @@ -49,15 +49,15 @@ def test_get_user_contoller_wrong_type_parameter(self): response = controller(request=request) assert response.status_code == 400 - assert response.body == "Field user_id isn't in the right type.\n Received: int.\n Expected: str" + assert response.body == "The field 'user_id' has the wrong type. Received: 'int'. Expected: 'str'." - def test_get_user_contoller_entity_error(self): + def test_get_user_controller_entity_error(self): repo = UserRepositoryMock() usecase = GetUserUsecase(repo=repo, observability=observability) controller = GetUserController(usecase=usecase, observability=observability) request = HttpRequest(query_params={ - 'user_id': 'abc' + 'user_id': 'a' }) response = controller(request=request) @@ -71,7 +71,7 @@ def test_get_user_controller_no_items_found(self): controller = GetUserController(usecase=usecase, observability=observability) request = HttpRequest(query_params={ - 'user_id': str(999) + 'user_id': str(uuid.uuid4()) }) response = controller(request=request) diff --git a/tests/modules/get_user/app/test_get_user_presenter.py b/tests/modules/get_user/app/test_get_user_presenter.py index 1526231..0dec7d3 100644 --- a/tests/modules/get_user/app/test_get_user_presenter.py +++ b/tests/modules/get_user/app/test_get_user_presenter.py @@ -22,7 +22,7 @@ def test_get_user(self): "header2": "value1,value2" }, "queryStringParameters": { - "user_id": "1" + "user_id": "5b20bcf8-f467-4569-83f2-1744534c162a" }, "requestContext": { "accountId": "123456789012", @@ -62,7 +62,6 @@ def test_get_user(self): response = lambda_handler(event, None) assert response["statusCode"] == 200 - assert json.loads(response["body"])["name"] == "Bruno Soller" - assert json.loads(response["body"])["email"] == "soller@soller.com" - assert json.loads(response["body"])["state"] == "APPROVED" - assert json.loads(response["body"])["user_id"] == 1 + assert json.loads(response["body"])["email"] == "admin@example.com" + assert json.loads(response["body"])["role"] == "admin" + assert json.loads(response["body"])["user_id"] == '5b20bcf8-f467-4569-83f2-1744534c162a' diff --git a/tests/modules/get_user/app/test_get_user_usecase.py b/tests/modules/get_user/app/test_get_user_usecase.py index cadb599..fa40b35 100644 --- a/tests/modules/get_user/app/test_get_user_usecase.py +++ b/tests/modules/get_user/app/test_get_user_usecase.py @@ -1,5 +1,5 @@ +import uuid import pytest - from src.modules.get_user.app.get_user_usecase import GetUserUsecase from src.shared.helpers.errors.domain_errors import EntityError from src.shared.helpers.errors.usecase_errors import NoItemsFound @@ -23,11 +23,11 @@ def test_get_user_not_found(self): usecase = GetUserUsecase(repo, observability=observability) with pytest.raises(NoItemsFound): - user = usecase(user_id=999) + user = usecase(user_id=uuid.uuid4()) def test_get_user_invalid_id(self): repo = UserRepositoryMock() usecase = GetUserUsecase(repo, observability=observability) with pytest.raises(EntityError): - user = usecase(user_id="invalid") + user = usecase(user_id=99) \ No newline at end of file diff --git a/tests/modules/get_user/app/test_get_user_viewmodel.py b/tests/modules/get_user/app/test_get_user_viewmodel.py index dae0fc6..f655a47 100644 --- a/tests/modules/get_user/app/test_get_user_viewmodel.py +++ b/tests/modules/get_user/app/test_get_user_viewmodel.py @@ -5,18 +5,13 @@ class Test_GetUserViewModel: def test_get_user_viewmodel(self): - user = User( - user_id=1, - name="Vitor Soller", - email="vitinho@hype.com", - state=STATE.APPROVED - ) + user = User(email="teste@teste.com", role="admin") + user_id = str(user.user_id) userViewmodel = GetUserViewmodel(user=user).to_dict() - expected = {'user_id': 1, - 'name': 'Vitor Soller', - 'email': 'vitinho@hype.com', - 'state': 'APPROVED', + expected = {'user_id': user_id, + 'email': 'teste@teste.com', + 'role': 'admin', 'message': 'the user was retrieved successfully'} - assert expected == userViewmodel + assert expected == userViewmodel \ No newline at end of file diff --git a/tests/modules/update_user/app/test_update_user_controller.py b/tests/modules/update_user/app/test_update_user_controller.py index 928d29d..c7f50a0 100644 --- a/tests/modules/update_user/app/test_update_user_controller.py +++ b/tests/modules/update_user/app/test_update_user_controller.py @@ -1,3 +1,4 @@ +import uuid from src.modules.update_user.app.update_user_controller import UpdateUserController from src.modules.update_user.app.update_user_usecase import UpdateUserUsecase from src.shared.helpers.external_interfaces.http_models import HttpRequest @@ -5,23 +6,24 @@ class Test_UpdateUserController: + def test_update_user_controller(self): repo = UserRepositoryMock() usecase = UpdateUserUsecase(repo=repo) controller = UpdateUserController(usecase=usecase) request = HttpRequest(body={ - 'user_id': "1", - 'new_name': 'Branco do Branco Branco da Silva' + 'user_id': '5b20bcf8-f467-4569-83f2-1744534c162a', + 'new_email': 'new_admin@epep.com', + 'new_role': 'user' }) response = controller(request=request) assert response.status_code == 200 - assert response.body['user_id'] == repo.users[0].user_id - assert response.body['name'] == 'Branco do Branco Branco da Silva' - assert response.body['email'] == repo.users[0].email - assert response.body['state'] == repo.users[0].state.value + assert response.body['user_id'] == str(repo.users[0].user_id) + assert response.body['email'] == 'new_admin@epep.com' + assert response.body['role'] == repo.users[0].role assert response.body['message'] == "the user was updated successfully" def test_update_user_controller_missing_user_id(self): @@ -30,7 +32,7 @@ def test_update_user_controller_missing_user_id(self): controller = UpdateUserController(usecase=usecase) request = HttpRequest(body={ - 'new_name': 'Branco do Branco Branco da Silva' + 'new_email': 'new_user@epep.com' }) response = controller(request=request) @@ -38,19 +40,21 @@ def test_update_user_controller_missing_user_id(self): assert response.status_code == 400 assert response.body == "Field user_id is missing" - def test_update_user_controller_missing_new_name(self): + + def test_update_user_controller_invalid_type_user_id(self): repo = UserRepositoryMock() usecase = UpdateUserUsecase(repo=repo) controller = UpdateUserController(usecase=usecase) request = HttpRequest(body={ - 'user_id': "1" + 'user_id': 3, + 'new_email': 'user@epep.com' }) response = controller(request=request) assert response.status_code == 400 - assert response.body == "Field new_name is missing" + assert response.body == "The field 'user_id' has the wrong type. Received: 'int'. Expected: 'str'." def test_update_user_controller_invalid_user_id(self): repo = UserRepositoryMock() @@ -58,14 +62,44 @@ def test_update_user_controller_invalid_user_id(self): controller = UpdateUserController(usecase=usecase) request = HttpRequest(body={ - 'user_id': 3, - 'new_name': 'Branco do Branco Branco da Silva' + 'user_id': 'a', + 'new_email': 'user@epep.com' + }) + + response = controller(request=request) + + assert response.status_code == 400 + assert response.body == 'Field user_id is not valid' + + def test_update_user_controller_invalid_email_type(self): + repo = UserRepositoryMock() + usecase = UpdateUserUsecase(repo=repo) + controller = UpdateUserController(usecase=usecase) + + request = HttpRequest(body={ + 'user_id': "5b20bcf8-f467-4569-83f2-1744534c162a", + 'new_email': 1 }) response = controller(request=request) assert response.status_code == 400 - assert response.body == "Field user_id isn't in the right type.\n Received: int.\n Expected: str" + assert response.body == "The field 'new_email' has the wrong type. Received: 'int'. Expected: 'str'." + + def test_update_user_controller_invalid_role(self): + repo = UserRepositoryMock() + usecase = UpdateUserUsecase(repo=repo) + controller = UpdateUserController(usecase=usecase) + + request = HttpRequest(body={ + 'user_id': "5b20bcf8-f467-4569-83f2-1744534c162a", + 'new_role': 2 + }) + + response = controller(request=request) + + assert response.status_code == 400 + assert response.body == "The field 'new_role' has the wrong type. Received: 'int'. Expected: 'str'." def test_update_user_not_found(self): repo = UserRepositoryMock() @@ -73,11 +107,13 @@ def test_update_user_not_found(self): controller = UpdateUserController(usecase=usecase) request = HttpRequest(body={ - 'user_id': "69", - 'new_name': 'Branco do Branco Branco da Silva' + 'user_id': str(uuid.uuid4()), + 'new_role': 'user' }) response = controller(request=request) assert response.status_code == 404 assert response.body == 'No items found for user_id' + + \ No newline at end of file diff --git a/tests/modules/update_user/app/test_update_user_presenter.py b/tests/modules/update_user/app/test_update_user_presenter.py index e1eab6f..a22341e 100644 --- a/tests/modules/update_user/app/test_update_user_presenter.py +++ b/tests/modules/update_user/app/test_update_user_presenter.py @@ -5,7 +5,7 @@ class Test_UpdateUserPresenter: - def test_update_user(self): + def test_update_user_email(self): event = { "version": "2.0", "routeKey": "$default", @@ -52,7 +52,7 @@ def test_update_user(self): "time": "12/Mar/2020:19:03:58 +0000", "timeEpoch": 1583348638390 }, - "body": '{"user_id": "1", "new_name": "João Soller"}', + "body": '{"user_id": "5b20bcf8-f467-4569-83f2-1744534c162a", "new_email": "admin@epep.com"}', "pathParameters": None, "isBase64Encoded": None, "stageVariables": None @@ -62,4 +62,127 @@ def test_update_user(self): assert response["statusCode"] == 200 - assert json.loads(response["body"])['name'] == 'João Soller' + assert json.loads(response["body"])['email'] == 'admin@epep.com' + + + def test_update_user_role(self): + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "header1": "value1", + "header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "1" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "authentication": None, + "authorizer": { + "iam": { + "accessKey": "AKIA...", + "accountId": "111122223333", + "callerId": "AIDA...", + "cognitoIdentity": None, + "principalOrgId": None, + "userArn": "arn:aws:iam::111122223333:user/example-user", + "userId": "AIDA..." + } + }, + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "external_interfaces": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": '{"user_id": "5b20bcf8-f467-4569-83f2-1744534c162a", "new_role": "user"}', + "pathParameters": None, + "isBase64Encoded": None, + "stageVariables": None + } + + response = lambda_handler(event, None) + + + assert response["statusCode"] == 200 + assert json.loads(response["body"])['role'] == 'user' + + + def test_update_user_role_and_email(self): + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "header1": "value1", + "header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "1" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "authentication": None, + "authorizer": { + "iam": { + "accessKey": "AKIA...", + "accountId": "111122223333", + "callerId": "AIDA...", + "cognitoIdentity": None, + "principalOrgId": None, + "userArn": "arn:aws:iam::111122223333:user/example-user", + "userId": "AIDA..." + } + }, + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "external_interfaces": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": '{"user_id": "5b20bcf8-f467-4569-83f2-1744534c162a", "new_email": "admin@epep.com", "new_role": "user"}', + "pathParameters": None, + "isBase64Encoded": None, + "stageVariables": None + } + + response = lambda_handler(event, None) + + + assert response["statusCode"] == 200 + assert json.loads(response["body"])['email'] == 'admin@epep.com' + assert json.loads(response["body"])['role'] == 'user' + + diff --git a/tests/modules/update_user/app/test_update_user_usecase.py b/tests/modules/update_user/app/test_update_user_usecase.py index 3e28f9a..67f627a 100644 --- a/tests/modules/update_user/app/test_update_user_usecase.py +++ b/tests/modules/update_user/app/test_update_user_usecase.py @@ -1,29 +1,37 @@ import pytest - +from uuid import UUID from src.modules.update_user.app.update_user_usecase import UpdateUserUsecase from src.shared.helpers.errors.domain_errors import EntityError from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock class Test_UpdateUserUsecase: - def test_update_user_usecase(selfs): + def test_update_user_usecase(self): repo = UserRepositoryMock() usecase = UpdateUserUsecase(repo=repo) - updated_user = usecase(user_id=1, new_name="Bruno Guirão MPNTM") + updated_user = usecase( + user_id=UUID('5b20bcf8-f467-4569-83f2-1744534c162a'), + new_email="admin@epep.com", + new_role='user' + ) + + assert updated_user.email == "admin@epep.com" + assert updated_user.role == "user" - assert updated_user.name == "Bruno Guirão MPNTM" - def test_update_user_usecase_wrong_user_id(selfs): + def test_update_user_usecase_wrong_new_email(self): repo = UserRepositoryMock() usecase = UpdateUserUsecase(repo=repo) with pytest.raises(EntityError): - usecase(user_id="1", new_name="Bruno Guirão MPNTM") + usecase( + user_id=UUID('5b20bcf8-f467-4569-83f2-1744534c162a'), + new_email=1 + ) - def test_update_user_usecase_wrong_new_name(selfs): + def test_update_user_usecase_wrong_new_role(self): repo = UserRepositoryMock() usecase = UpdateUserUsecase(repo=repo) with pytest.raises(EntityError): - usecase(user_id=1, new_name=1) - + usecase(user_id=UUID('5b20bcf8-f467-4569-83f2-1744534c162a'), new_role='a') diff --git a/tests/modules/update_user/app/test_update_user_viewmodel.py b/tests/modules/update_user/app/test_update_user_viewmodel.py index 877c052..777a85a 100644 --- a/tests/modules/update_user/app/test_update_user_viewmodel.py +++ b/tests/modules/update_user/app/test_update_user_viewmodel.py @@ -1,20 +1,20 @@ from src.modules.update_user.app.update_user_viewmodel import UpdateUserViewmodel from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE class Test_UpadateUserViewmodel: def test_update_user_viewmodel(self): - user = User(user_id=1, name="Test", email="teste@test.com", state=STATE.APPROVED) + + user = User(email="teste@test.com", role=ROLE.ADMIN) - updated_useer_viewmodel = UpdateUserViewmodel(user) + updated_user_viewmodel = UpdateUserViewmodel(user) expected = { - 'user_id': 1, - 'name': "Test", + 'user_id': str(user.user_id), 'email': "teste@test.com", - 'state': "APPROVED", + 'role': "admin", 'message': "the user was updated successfully" } - assert expected == updated_useer_viewmodel.to_dict() + assert expected == updated_user_viewmodel.to_dict() diff --git a/tests/shared/domain/entities/test_member.py b/tests/shared/domain/entities/test_member.py new file mode 100644 index 0000000..0ac30e5 --- /dev/null +++ b/tests/shared/domain/entities/test_member.py @@ -0,0 +1,475 @@ +import pytest +import uuid + +from src.shared.domain.entities.member import Member +from src.shared.domain.enums.member_function_enum import MemberFunctionEnum +from src.shared.domain.enums.member_status_enum import MemberStatusEnum +from src.shared.helpers.errors.domain_errors import EntityError + + +class Test_Member: + + def test_member(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + assert isinstance(member.member_id, uuid.UUID) + assert member.name == "Nome Completo do Membro" + assert member.function == "Marketing" + assert member.status == "Ativo" + assert str(member.linkedin) == "https://www.linkedin.com/" + assert str(member.photo) == "https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png" + assert member.description == "Exemplo de descrição do membro" + + + + # model_config + + def test_member_extra_field(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro", + extra_field="Campo extra" + ) + + + + # name + + def test_member_name_with_numbers(self): + with pytest.raises(EntityError): + member = Member( + name="N0me 1nválido", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_name_with_invalid_symbols(self): + with pytest.raises(EntityError): + member = Member( + name="[Nome Inválido!]", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_name_too_short(self): + with pytest.raises(EntityError): + member = Member( + name="N", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_name_min_length(self): + member = Member( + name="Ju", + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + assert member.name == "Ju" + + + def test_member_name_empty(self): + with pytest.raises(EntityError): + member = Member( + name="", + function="VicePresidencia", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_has_no_name(self): + with pytest.raises(EntityError): + member = Member( + function="Marketing", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + + # function + + def test_member_function_invalid(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Cargo Inválido", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_function_valid_enum(self): + member = Member( + name="Nome Completo do Membro", + function=MemberFunctionEnum.RH, + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + assert member.function == "RH" + + + def test_member_function_empty(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_has_no_function(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + status="Ativo", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + + # status + + def test_member_status_invalid(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Presidencia", + status="Status Inválido", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_status_valid_enum(self): + member = Member( + name="Nome Completo do Membro", + function="Eventos", + status=MemberStatusEnum.CONGELADO, + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + assert member.status == "Congelado" + + + def test_member_status_empty(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Financeiro", + status="", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_has_no_status(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="RH", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + + # linkedin + + def test_member_linkedin_invalid_url(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="Não é uma url", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_linkedin_no_scheme(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="www.linkedin.com", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_linkedin_empty(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_linkedin_normalizes_slash(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + assert str(member.linkedin) == "https://www.linkedin.com/" + + + def test_member_has_no_linkedin(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + + # photo + + def test_member_photo_invalid_url(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="Não é URL", + description="Exemplo de descrição do membro" + ) + + + def test_member_photo_no_scheme(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Exemplo de descrição do membro" + ) + + + def test_member_photo_url_no_file_extension(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/", + description="Exemplo de descrição do membro" + ) + + + def test_member_photo_accepts_png(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.png", + description="Exemplo de descrição do membro" + ) + assert str(member.photo).endswith(".png") + + + def test_member_photo_accepts_jpg(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.jpg", + description="Exemplo de descrição do membro" + ) + assert str(member.photo).endswith(".jpg") + + + def test_member_photo_accepts_jpeg(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.jpeg", + description="Exemplo de descrição do membro" + ) + assert str(member.photo).endswith(".jpeg") + + + def test_member_photo_accepts_webp(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.webp", + description="Exemplo de descrição do membro" + ) + assert str(member.photo).endswith(".webp") + + + def test_member_photo_rejects_other_extensions(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.pdf", + description="Exemplo de descrição do membro" + ) + + + def test_member_photo_case_insensitive(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://www.exemplo.com/imagem.PNG", + description="Exemplo de descrição do membro" + ) + assert str(member.photo).endswith(".PNG") + + + def test_member_photo_empty(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="", + description="Exemplo de descrição do membro" + ) + + + def test_member_has_no_photo(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + description="Exemplo de descrição do membro" + ) + + + + # description + + def test_member_description_with_numbers(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Descrição: membro melhorou em 10%. (com parênteses)!" + ) + assert member.description == "Descrição: membro melhorou em 10%. (com parênteses)!" + + + def test_member_description_too_short(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Abcd" + ) + + + def test_member_description_min_length(self): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="Curto" + ) + assert member.description == "Curto" + + + def test_member_description_empty(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png", + description="" + ) + + + def test_member_has_no_description(self): + with pytest.raises(EntityError): + member = Member( + name="Nome Completo do Membro", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/", + photo="https://portalinterno.devmaua.com/assets/logo_dev-ec58e665.png" + ) diff --git a/tests/shared/domain/entities/test_user.py b/tests/shared/domain/entities/test_user.py index b5f2a85..85bbef6 100644 --- a/tests/shared/domain/entities/test_user.py +++ b/tests/shared/domain/entities/test_user.py @@ -1,41 +1,43 @@ +import uuid +import pytest + from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE from src.shared.helpers.errors.domain_errors import EntityError -import pytest class Test_User: def test_user(self): - User(name="VITOR", email="21.01444-2@maua.br", user_id=1, state=STATE.APPROVED) + user = User(email="usuario@example.com", ) + id_user = user.user_id + assert isinstance(id_user, uuid.UUID) + assert user.email == "usuario@example.com" + assert user.role == "user" - def test_user_name_is_none(self): + def test_user_not_has_email(self): with pytest.raises(EntityError): - User(name=None, email="21.01444-2@maua.br", user_id=1, state=STATE.APPROVED) + User(role="admin") - def test_user_name_is_not_str(self): - with pytest.raises(EntityError): - User(name=1, email="21.01444-2@maua.br", user_id=1, state=STATE.APPROVED) + def test_user_with_custom_id(self): + user_id = uuid.uuid4() + user = User(user_id=user_id, email="usuario@example.com", ) + assert user.user_id == user_id - def test_user_name_is_shorter_than_min_length(self): - with pytest.raises(EntityError): - User(name="V", email="21.01444-2@maua.br", user_id=1, state=STATE.APPROVED) + def test_user_role_is_admin(self): + user = User(email="admin@example.com", role="admin") + assert user.role == "admin" - def test_user_email_is_none(self): - with pytest.raises(EntityError): - User(name="VITOR", email=None, user_id=1, state=STATE.APPROVED) - - def test_user_email_is_not_valid(self): + def test_user_role_is_invalid(self): with pytest.raises(EntityError): - User(name="VITOR", email="21.01444-2maua.br", user_id=1, state=STATE.APPROVED) + User(email="usuario@example.com", role="invalid_role") - def test_user_user_id_is_not_int(self): + def test_user_email_is_none(self): with pytest.raises(EntityError): - User(name="VITOR", email="21.01444-2@maua.br", user_id="1", state=STATE.APPROVED) + User(email=None) - def test_user_user_id_is_negative(self): + def test_user_email_not_has_at_symbol(self): with pytest.raises(EntityError): - User(name="VITOR", email="21.01444-2@maua.br", user_id=-1, state=STATE.APPROVED) + User(email="usuarioexample.com") - def test_user_state_is_not_sate_enum(self): + def test_user_email_not_has_domain(self): with pytest.raises(EntityError): - User(name="VITOR", email="21.01444-2@maua.br", user_id=1, state="APPROVED") + User(email="usuario@") diff --git a/tests/shared/helpers/auth/test_iam_policy.py b/tests/shared/helpers/auth/test_iam_policy.py new file mode 100644 index 0000000..3769dda --- /dev/null +++ b/tests/shared/helpers/auth/test_iam_policy.py @@ -0,0 +1,21 @@ +from src.shared.helpers.auth.iam_policy import generate_policy + + +class Test_GeneratePolicy: + def test_allow_policy(self): + policy = generate_policy("principal-1", "Allow", "arn:aws:execute-api:sa-east-1:123:api/GET/users") + + assert policy["principalId"] == "principal-1" + assert policy["policyDocument"]["Statement"][0]["Effect"] == "Allow" + assert "context" not in policy + + def test_deny_policy_with_context(self): + policy = generate_policy( + "user", + "Deny", + "arn:aws:execute-api:*", + context={"user": "{}"}, + ) + + assert policy["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert policy["context"]["user"] == "{}" diff --git a/tests/shared/helpers/external_interfaces/test_lambda_http_request_authorizer.py b/tests/shared/helpers/external_interfaces/test_lambda_http_request_authorizer.py new file mode 100644 index 0000000..7f4aa2b --- /dev/null +++ b/tests/shared/helpers/external_interfaces/test_lambda_http_request_authorizer.py @@ -0,0 +1,56 @@ +import json + +from src.shared.helpers.auth.authorizer_user import USER_FROM_AUTHORIZER_KEY +from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest + + +class Test_LambdaHttpRequestAuthorizerUser: + def test_injects_user_from_authorizer_into_data(self): + user = { + "sub": "ms-admin", + "mail": "alice@example.com", + "name": "Alice Admin", + } + event = { + "headers": {}, + "queryStringParameters": None, + "body": json.dumps({"email": "payload@example.com"}), + "requestContext": { + "http": {"method": "POST", "path": "/create-user"}, + "authorizer": {"user": json.dumps(user)}, + }, + } + + request = LambdaHttpRequest(data=event) + + assert request.data.get(USER_FROM_AUTHORIZER_KEY) == user + assert request.data.get("email") == "payload@example.com" + + def test_overwrites_spoofed_body_user_from_authorizer(self): + event = { + "headers": {}, + "queryStringParameters": None, + "body": json.dumps({ + USER_FROM_AUTHORIZER_KEY: { + "sub": "spoof", + "mail": "spoof@example.com", + "name": "Spoof", + }, + "email": "payload@example.com", + }), + "requestContext": { + "http": {"method": "POST", "path": "/create-user"}, + "authorizer": { + "user": json.dumps({ + "sub": "ms-user", + "mail": "bob@example.com", + "name": "Bob User", + }) + }, + }, + } + + request = LambdaHttpRequest(data=event) + + assert request.data[USER_FROM_AUTHORIZER_KEY]["mail"] == "bob@example.com" + assert request.data[USER_FROM_AUTHORIZER_KEY]["sub"] == "ms-user" diff --git a/tests/shared/infra/dto/test_member_dynamo_dto.py b/tests/shared/infra/dto/test_member_dynamo_dto.py new file mode 100644 index 0000000..521e168 --- /dev/null +++ b/tests/shared/infra/dto/test_member_dynamo_dto.py @@ -0,0 +1,35 @@ +import uuid + +from src.shared.domain.enums.member_function_enum import MemberFunctionEnum +from src.shared.domain.enums.member_status_enum import MemberStatusEnum +from src.shared.infra.dto.member_dynamo_dto import MemberDynamoDTO +from src.shared.infra.repositories.member_repository_mock import MemberRepositoryMock + + +class Test_MemberDynamoDTO: + def test_from_entity_to_dynamo(self): + member = MemberRepositoryMock().members[0] + data = MemberDynamoDTO.from_entity_to_dynamo(member) + + assert data["pk"] == "MEMBER" + assert data["sk"] == f"MEMBER#{member.member_id}" + assert data["name"] == member.name + assert data["function"] == MemberFunctionEnum.MARKETING.value + assert data["status"] == MemberStatusEnum.ATIVO.value + assert data["linkedin"] == str(member.linkedin) + assert data["photo"] == str(member.photo) + assert data["description"] == member.description + assert "member_id" in data + + def test_from_dynamo_to_entity_roundtrip(self): + member = MemberRepositoryMock().members[1] + dynamo = MemberDynamoDTO.from_entity_to_dynamo(member) + restored = MemberDynamoDTO.from_dynamo_to_entity(dynamo) + + assert restored.member_id == member.member_id + assert restored.name == member.name + assert restored.function == MemberFunctionEnum.REDACAO.value + assert restored.status == MemberStatusEnum.CONGELADO.value + assert str(restored.linkedin) == str(member.linkedin) + assert str(restored.photo) == str(member.photo) + assert restored.description == member.description \ No newline at end of file diff --git a/tests/shared/infra/dto/test_template_dynamo_dto.py b/tests/shared/infra/dto/test_template_dynamo_dto.py new file mode 100644 index 0000000..78904ed --- /dev/null +++ b/tests/shared/infra/dto/test_template_dynamo_dto.py @@ -0,0 +1,27 @@ +""" from src.shared.domain.enums.role_enum import ROLE +from src.shared.infra.dto.template_dynamo_dto import UserDynamoDTO +from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock + + +class Test_UserDynamoDTO: + def test_from_entity_to_dynamo(self): + user = UserRepositoryMock().users[0] + data = UserDynamoDTO.from_entity_to_dynamo(user) + + assert data["pk"] == "USER" + assert data["sk"] == f"USER#{user.user_id}" + assert data["gsi2pk"] == f"EMAIL#{user.email}" + assert data["gsi2sk"] == f"USER#{user.user_id}" + assert data["email"] == user.email + assert data["role"] == ROLE.ADMIN.value + assert "user_id" in data + + def test_from_dynamo_to_entity_roundtrip(self): + user = UserRepositoryMock().users[1] + dynamo = UserDynamoDTO.from_entity_to_dynamo(user) + restored = UserDynamoDTO.from_dynamo_to_entity(dynamo) + + assert restored.user_id == user.user_id + assert restored.email == user.email + assert restored.role == ROLE.USER + """ \ No newline at end of file diff --git a/tests/shared/infra/dto/test_user_dynamo_dto.py b/tests/shared/infra/dto/test_user_dynamo_dto.py index a3a71ad..3ccf1be 100644 --- a/tests/shared/infra/dto/test_user_dynamo_dto.py +++ b/tests/shared/infra/dto/test_user_dynamo_dto.py @@ -1,132 +1,26 @@ -from decimal import Decimal - -from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE -from src.shared.infra.dto.user_dynamo_dto import UserDynamoDTO +from src.shared.domain.enums.role_enum import ROLE +from src.shared.infra.dto.template_dynamo_dto import UserDynamoDTO from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock -class Test_UserDynamoDto: - def test_from_entity(self): - repo = UserRepositoryMock() - - user_dto = UserDynamoDTO.from_entity(user=repo.users[0]) - - expected_selfie_dto = UserDynamoDTO( - name=repo.users[0].name, - email=repo.users[0].email, - user_id=repo.users[0].user_id, - state=repo.users[0].state - ) - - assert user_dto == expected_selfie_dto - - def test_to_dynamo(self): - repo = UserRepositoryMock() - - user_dto = UserDynamoDTO( - name=repo.users[0].name, - email=repo.users[0].email, - user_id=repo.users[0].user_id, - state=repo.users[0].state - ) - - user_dynamo = user_dto.to_dynamo() - - expected_dict = { - "entity": "user", - "name": repo.users[0].name, - "email": repo.users[0].email, - "user_id": repo.users[0].user_id, - "state": repo.users[0].state.value - } - - assert user_dto.to_dynamo() == expected_dict - - def test_from_dynamo(self): - dynamo_dict = {'Item': {'user_id': Decimal('1'), - 'name': 'Bruno Soller', - 'SK': '#1', - 'state': 'APPROVED', - 'PK': 'user#1', - 'entity': 'user', - 'email': 'soller@soller.com'}, - 'ResponseMetadata': {'RequestId': 'aa6a5e5e-943f-4452-8c1f-4e5441ee6042', - 'HTTPStatusCode': 200, - 'HTTPHeaders': {'date': 'Fri, 16 Dec 2022 15:40:29 GMT', - 'content-type': 'application/x-amz-json-1.0', - 'x-amz-crc32': '3909675734', - 'x-amzn-requestid': 'aa6a5e5e-943f-4452-8c1f-4e5441ee6042', - 'content-length': '174', - 'server': 'Jetty(9.4.48.v20220622)'}, - 'RetryAttempts': 0}} - - user_dto = UserDynamoDTO.from_dynamo(user_data=dynamo_dict["Item"]) - - expected_user_dto = UserDynamoDTO( - name="Bruno Soller", - email='soller@soller.com', - user_id=1, - state=STATE.APPROVED - ) - - assert user_dto == expected_user_dto - - def test_to_entity(self): - repo = UserRepositoryMock() - - user_dto = UserDynamoDTO( - name=repo.users[0].name, - email=repo.users[0].email, - user_id=repo.users[0].user_id, - state=repo.users[0].state - ) - - user = user_dto.to_entity() - - assert user.name == repo.users[0].name - assert user.email == repo.users[0].email - assert user.user_id == repo.users[0].user_id - assert user.state == repo.users[0].state - - def test_from_dynamo_to_entity(self): - dynamo_item = {'Item': {'user_id': Decimal('1'), - 'name': 'Bruno Soller', - 'SK': '#1', - 'state': 'APPROVED', - 'PK': 'user#1', - 'entity': 'user', - 'email': 'soller@soller.com'}} - - user_dto = UserDynamoDTO.from_dynamo(user_data=dynamo_item["Item"]) - - user = user_dto.to_entity() - - expected_user = User( - name="Bruno Soller", - email="soller@soller.com", - user_id=1, - state=STATE.APPROVED - ) - - assert user.name == expected_user.name - assert user.email == expected_user.email - assert user.user_id == expected_user.user_id - assert user.state == expected_user.state - +class Test_UserDynamoDTO: def test_from_entity_to_dynamo(self): - repo = UserRepositoryMock() - - user_dto = UserDynamoDTO.from_entity(user=repo.users[0]) - - user_dynamo = user_dto.to_dynamo() - - expected_dict = { - "entity": "user", - "name": repo.users[0].name, - "email": repo.users[0].email, - "user_id": repo.users[0].user_id, - "state": repo.users[0].state.value - } - - assert user_dynamo == expected_dict + user = UserRepositoryMock().users[0] + data = UserDynamoDTO.from_entity_to_dynamo(user) + + assert data["pk"] == "USER" + assert data["sk"] == f"USER#{user.user_id}" + assert data["gsi2pk"] == f"EMAIL#{user.email}" + assert data["gsi2sk"] == f"USER#{user.user_id}" + assert data["email"] == user.email + assert data["role"] == ROLE.ADMIN.value + assert "user_id" in data + + def test_from_dynamo_to_entity_roundtrip(self): + user = UserRepositoryMock().users[1] + dynamo = UserDynamoDTO.from_entity_to_dynamo(user) + restored = UserDynamoDTO.from_dynamo_to_entity(dynamo) + + assert restored.user_id == user.user_id + assert restored.email == user.email + assert restored.role == ROLE.USER diff --git a/tests/shared/infra/external/dynamo/test_dynamo_keys.py b/tests/shared/infra/external/dynamo/test_dynamo_keys.py new file mode 100644 index 0000000..88752cc --- /dev/null +++ b/tests/shared/infra/external/dynamo/test_dynamo_keys.py @@ -0,0 +1,53 @@ +from uuid import UUID + +from src.shared.infra.external.dynamo.dynamo_keys import ( + EntityKind, + GSI2_NAME, + GSI2_PK_ATTR, + GSI2_SK_ATTR, + PK_ATTR, + SK_ATTR, + build_gsi2_attributes, + partition_key, + sort_key, + strip_keys, +) + + +class Test_DynamoKeys: + def test_user_base_keys(self): + user_id = UUID("11111111-1111-1111-1111-111111111111") + + assert PK_ATTR == "pk" + assert SK_ATTR == "sk" + assert partition_key(EntityKind.USER) == "USER" + assert sort_key(user_id, EntityKind.USER) == f"USER#{user_id}" + + def test_member_and_subscriber_kinds(self): + member_id = UUID("22222222-2222-2222-2222-222222222222") + subscriber_id = UUID("33333333-3333-3333-3333-333333333333") + + assert partition_key(EntityKind.MEMBER) == "MEMBER" + assert sort_key(member_id, EntityKind.MEMBER) == f"MEMBER#{member_id}" + assert partition_key(EntityKind.SUBSCRIBER) == "SUBSCRIBER" + assert sort_key(subscriber_id, EntityKind.SUBSCRIBER) == f"SUBSCRIBER#{subscriber_id}" + + def test_user_email_gsi(self): + user_id = UUID("11111111-1111-1111-1111-111111111111") + attrs = build_gsi2_attributes("user@example.com", user_id) + + assert GSI2_NAME == "UserEmailIndex" + assert attrs[GSI2_PK_ATTR] == "EMAIL#user@example.com" + assert attrs[GSI2_SK_ATTR] == f"USER#{user_id}" + + def test_strip_keys(self): + user_id = UUID("11111111-1111-1111-1111-111111111111") + item = { + "pk": "USER", + "sk": f"USER#{user_id}", + "gsi2pk": "EMAIL#user@example.com", + "gsi2sk": f"USER#{user_id}", + "email": "user@example.com", + } + + assert strip_keys(item) == {"email": "user@example.com"} diff --git a/tests/shared/infra/repositories/test_member_repository_dynamo.py b/tests/shared/infra/repositories/test_member_repository_dynamo.py new file mode 100644 index 0000000..81e9a96 --- /dev/null +++ b/tests/shared/infra/repositories/test_member_repository_dynamo.py @@ -0,0 +1,81 @@ +import os +import pytest + +from src.shared.domain.entities.member import Member +from src.shared.infra.repositories.member_repository_dynamo import MemberRepositoryDynamo +from src.shared.infra.repositories.member_repository_mock import MemberRepositoryMock + + +class Test_MemberRepositoryDynamo: + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_create_member(self): + os.environ["STAGE"] = "TEST" + + member_repository = MemberRepositoryDynamo() + member_repository_mock = MemberRepositoryMock() + resp = member_repository.create_member(member_repository_mock.members[0]) + + assert member_repository_mock.members[0].name == resp.name + + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_get_member(self): + os.environ["STAGE"] = "TEST" + + member_repository = MemberRepositoryDynamo() + member_repository_mock = MemberRepositoryMock() + + created = member_repository.create_member(member_repository_mock.members[0]) + resp = member_repository.get_member(created.member_id) + + assert created.name == resp.name + + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_delete_member(self): + os.environ["STAGE"] = "TEST" + + member_repository = MemberRepositoryDynamo() + member_repository_mock = MemberRepositoryMock() + + created = member_repository.create_member(member_repository_mock.members[0]) + resp = member_repository.delete_member(created.member_id) + + assert created.name == resp.name + + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_get_all_member(self): + os.environ["STAGE"] = "TEST" + + member_repository = MemberRepositoryDynamo() + member_repository_mock = MemberRepositoryMock() + + created = member_repository.create_member(member_repository_mock.members[0]) + resp = member_repository.get_all_member() + + assert any(m.member_id == created.member_id for m in resp) + + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_update_member(self): + os.environ["STAGE"] = "TEST" + + member_repository = MemberRepositoryDynamo() + member_repository_mock = MemberRepositoryMock() + + created = member_repository.create_member(member_repository_mock.members[0]) + + updated_member = Member( + member_id=created.member_id, + name="Nome do Membro Atualizado", + function=created.function, + status=created.status, + linkedin=created.linkedin, + photo=created.photo, + description=created.description + ) + resp = member_repository.update_member(updated_member) + + assert resp.name == "Nome do Membro Atualizado" diff --git a/tests/shared/infra/repositories/test_member_repository_mock.py b/tests/shared/infra/repositories/test_member_repository_mock.py new file mode 100644 index 0000000..7de1fca --- /dev/null +++ b/tests/shared/infra/repositories/test_member_repository_mock.py @@ -0,0 +1,214 @@ +import uuid +import pytest + +from src.shared.domain.entities.member import Member +from src.shared.domain.enums.member_function_enum import MemberFunctionEnum +from src.shared.helpers.errors.usecase_errors import NoItemsFound +from src.shared.infra.repositories.member_repository_mock import MemberRepositoryMock + + +class Test_MemberRepositoryMock: + + def test_create_member(self): + repo = MemberRepositoryMock() + member = Member( + name="Membro Três", + function="Marketing", + status="Desativado", + linkedin="https://www.linkedin.com/3", + photo="https://portalinterno.devmaua.com/assets/logo_fake3.png", + description="Descrição do terceiro membro do repo" + ) + repo.create_member(member) + assert repo.members[-1].name == "Membro Três" + assert repo.members[-1].function == "Marketing" + assert repo.members[-1].status == "Desativado" + assert str(repo.members[-1].linkedin) =="https://www.linkedin.com/3" + assert str(repo.members[-1].photo) =="https://portalinterno.devmaua.com/assets/logo_fake3.png" + assert repo.members[-1].description =="Descrição do terceiro membro do repo" + assert len(repo.members) == 3 + + + def test_get_member(self): + repo = MemberRepositoryMock() + member_id = repo.get_all_member()[1].member_id + member = repo.get_member(member_id) + + assert member.name == "Nome do Membro Dois" + assert member.function == "Redacao" + assert member.status == "Congelado" + assert str(member.linkedin) == "https://www.linkedin.com/2" + assert str(member.photo) == "https://portalinterno.devmaua.com/assets/logo_fake2.png" + assert member.description == "Exemplo de descrição do membro 2" + + + def test_get_member_not_found(self): + repo = MemberRepositoryMock() + with pytest.raises(NoItemsFound): + repo.get_member(uuid.uuid4()) + + + def test_get_all_member(self): + repo = MemberRepositoryMock() + members = repo.get_all_member() + + assert len(members) == 2 + + + def test_update_member_function(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member_old_function = member.function + member_name = member.name + + member = Member( + member_id=member.member_id, + name=member.name, + function="Projetos", + status=member.status, + linkedin=member.linkedin, + photo=member.photo, + description=member.description + ) + updated_member = repo.update_member(member) + + assert updated_member is not None + assert updated_member.name == member_name + assert updated_member.function != member_old_function + assert updated_member.function == "Projetos" + assert repo.members[0].function == "Projetos" + + + def test_update_member_status(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member_old_status = member.status + member_name = member.name + + member = Member( + member_id=member.member_id, + name=member.name, + function=member.function, + status="Desativado", + linkedin=member.linkedin, + photo=member.photo, + description=member.description + ) + updated_member = repo.update_member(member) + + assert updated_member is not None + assert updated_member.name == member_name + assert updated_member.status != member_old_status + assert updated_member.status == "Desativado" + assert repo.members[0].status == "Desativado" + + + def test_update_member_linkedin(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member_old_linkedin = member.linkedin + member_name = member.name + + member = Member( + member_id=member.member_id, + name=member.name, + function=member.function, + status=member.status, + linkedin="https://www.linkedin.com/novo", + photo=member.photo, + description=member.description + ) + updated_member = repo.update_member(member) + + assert updated_member is not None + assert updated_member.name == member_name + assert updated_member.linkedin != member_old_linkedin + assert str(updated_member.linkedin) == "https://www.linkedin.com/novo" + assert str(repo.members[0].linkedin) == "https://www.linkedin.com/novo" + + + def test_update_member_photo(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member_old_photo = member.photo + member_name = member.name + + member = Member( + member_id=member.member_id, + name=member.name, + function=member.function, + status=member.status, + linkedin=member.linkedin, + photo="https://www.photo.com/photo1.jpg", + description=member.description + ) + updated_member = repo.update_member(member) + + assert updated_member is not None + assert updated_member.name == member_name + assert updated_member.photo != member_old_photo + assert str(updated_member.photo) == "https://www.photo.com/photo1.jpg" + assert str(repo.members[0].photo) == "https://www.photo.com/photo1.jpg" + + + def test_update_member_description(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member_old_description = member.description + member_name = member.name + + member = Member( + member_id=member.member_id, + name=member.name, + function=member.function, + status=member.status, + linkedin=member.linkedin, + photo=member.photo, + description="Nova descrição do membro" + ) + updated_member = repo.update_member(member) + + assert updated_member is not None + assert updated_member.name == member_name + assert updated_member.description != member_old_description + assert updated_member.description == "Nova descrição do membro" + assert repo.members[0].description == "Nova descrição do membro" + + + def test_update_member_not_found(self): + repo = MemberRepositoryMock() + member = repo.get_all_member()[0] + + member = Member( + # new member_id + name=member.name, + function=member.function, + status=member.status, + linkedin=member.linkedin, + photo=member.photo, + description=member.description + ) + + with pytest.raises(NoItemsFound): + repo.update_member(member) + + + def test_delete_member(self): + repo = MemberRepositoryMock() + member_id = repo.get_all_member()[1].member_id + member = repo.delete_member(member_id) + + assert member.name == "Nome do Membro Dois" + assert len(repo.members) == 1 + + + def test_delete_member_not_found(self): + repo = MemberRepositoryMock() + with pytest.raises(NoItemsFound): + repo.delete_member(uuid.uuid4()) + diff --git a/tests/shared/infra/repositories/test_template_repository_dynamo.py b/tests/shared/infra/repositories/test_template_repository_dynamo.py new file mode 100644 index 0000000..6ad721a --- /dev/null +++ b/tests/shared/infra/repositories/test_template_repository_dynamo.py @@ -0,0 +1,60 @@ +""" import os + +import pytest + +from src.shared.infra.repositories.template_repository_dynamo import TemplateRepositoryDynamo +from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock + + +class Test_TemplateRepositoryDynamo: + @pytest.mark.skip(reason="Needs dynamoDB") + def test_create_user(self): + os.environ["STAGE"] = "TEST" + + user_repository = TemplateRepositoryDynamo() + user_repository_mock = UserRepositoryMock() + resp = user_repository.create_user(user_repository_mock.users[0]) + + assert user_repository_mock.users[0].email == resp.email + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_get_user(self): + os.environ["STAGE"] = "TEST" + + user_repository = TemplateRepositoryDynamo() + user_repository_mock = UserRepositoryMock() + resp = user_repository.get_user(user_repository_mock.users[0].user_id) + + assert user_repository_mock.users[0].email == resp.email + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_delete_user(self): + os.environ["STAGE"] = "TEST" + + user_repository = TemplateRepositoryDynamo() + user_repository_mock = UserRepositoryMock() + resp = user_repository.delete_user(user_repository_mock.users[0].user_id) + + assert user_repository_mock.users[0].email == resp.email + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_get_all_user(self): + os.environ["STAGE"] = "TEST" + + user_repository = TemplateRepositoryDynamo() + user_repository_mock = UserRepositoryMock() + resp = user_repository.get_all_user() + + assert len(user_repository_mock.users) == len(resp) + + @pytest.mark.skip(reason="Needs dynamoDB") + def test_update_user(self): + os.environ["STAGE"] = "TEST" + + user_repository = TemplateRepositoryDynamo() + user_repository_mock = UserRepositoryMock() + updated = user_repository_mock.users[0].model_copy(update={"password_hash": "novo_hash"}) + resp = user_repository.update_user(updated) + + assert resp.password_hash == "novo_hash" + """ diff --git a/tests/shared/infra/repositories/test_user_repository_dynamo.py b/tests/shared/infra/repositories/test_user_repository_dynamo.py index 2c3c519..edb556f 100644 --- a/tests/shared/infra/repositories/test_user_repository_dynamo.py +++ b/tests/shared/infra/repositories/test_user_repository_dynamo.py @@ -15,7 +15,7 @@ def test_create_user(self): user_repository_mock = UserRepositoryMock() resp = user_repository.create_user(user_repository_mock.users[0]) - assert user_repository_mock.users[0].name == resp.name + assert user_repository_mock.users[0].email == resp.email @pytest.mark.skip(reason="Needs dynamoDB") def test_get_user(self): @@ -23,36 +23,36 @@ def test_get_user(self): user_repository = UserRepositoryDynamo() user_repository_mock = UserRepositoryMock() - resp = user_repository.get_user(1) + resp = user_repository.get_user(user_repository_mock.users[0].user_id) - assert user_repository_mock.users[0].name == resp.name + assert user_repository_mock.users[0].email == resp.email @pytest.mark.skip(reason="Needs dynamoDB") - def test_delete_user(self): + def test_get_all_user(self): os.environ["STAGE"] = "TEST" user_repository = UserRepositoryDynamo() user_repository_mock = UserRepositoryMock() - resp = user_repository.delete_user(3) - - assert user_repository_mock.users[2].name == resp.name + resp = user_repository.get_all_user() + assert len(user_repository_mock.users) == len(resp) - @pytest.mark.skip(reason="Needs dynamoDB") - def test_get_all_user(self): + @pytest.mark.skip(reason="Needs dynamoDB") + def test_update_user(self): os.environ["STAGE"] = "TEST" user_repository = UserRepositoryDynamo() user_repository_mock = UserRepositoryMock() - resp = user_repository.get_all_user() + updated = user_repository_mock.users[0].model_copy(update={"email": "novo_email@epep.com"}) + resp = user_repository.update_user(updated) - assert len(user_repository_mock.users) == len(resp) + assert resp.email == "novo_email@epep.com" @pytest.mark.skip(reason="Needs dynamoDB") - def test_update_user(self): + def test_delete_user(self): os.environ["STAGE"] = "TEST" user_repository = UserRepositoryDynamo() user_repository_mock = UserRepositoryMock() - resp = user_repository.update_user(user_id=1, new_name="Vitor Soller Soller") + resp = user_repository.delete_user(user_repository_mock.users[0].user_id) - assert resp.name == "Vitor Soller Soller" + assert "novo_email@epep.com" == resp.email \ No newline at end of file diff --git a/tests/shared/infra/repositories/test_user_repository_mock.py b/tests/shared/infra/repositories/test_user_repository_mock.py index 4dcda8b..4f14420 100644 --- a/tests/shared/infra/repositories/test_user_repository_mock.py +++ b/tests/shared/infra/repositories/test_user_repository_mock.py @@ -1,75 +1,72 @@ +import uuid +import pytest + from src.shared.domain.entities.user import User -from src.shared.domain.enums.state_enum import STATE +from src.shared.domain.enums.role_enum import ROLE from src.shared.helpers.errors.usecase_errors import NoItemsFound from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock -import pytest class Test_UserRepositoryMock: + + def test_create_user(self): + repo = UserRepositoryMock() + user = User( + email="dohype@vitin.com", + ) + repo.create_user(user) + assert repo.users[-1].email == "dohype@vitin.com" + assert repo.users[-1].role == "user" + assert len(repo.users) == 3 + def test_get_user(self): repo = UserRepositoryMock() - user = repo.get_user(1) + user_id = repo.get_all_user()[1].user_id + user = repo.get_user(user_id) - assert user.name == "Bruno Soller" - assert user.email == "soller@soller.com" - assert user.user_id == 1 - assert user.state == STATE.APPROVED + assert user.email == "user@example.com" + assert user.role == "user" def test_get_user_not_found(self): repo = UserRepositoryMock() with pytest.raises(NoItemsFound): - user = repo.get_user(69) + repo.get_user(uuid.uuid4()) def test_get_all_user(self): repo = UserRepositoryMock() users = repo.get_all_user() - assert len(users) == 3 + assert len(users) == 2 - def test_create_user(self): + + def test_update_user_role(self): repo = UserRepositoryMock() + user = repo.get_all_user()[0] user = User( - name="Vitor Soller", - email="dohype@vitin.com", - user_id=4, - state=STATE.PENDING + user_id=user.user_id, + email=user.email, + role=ROLE.ADMIN, ) - repo.create_user(user) - - assert repo.users[3].name == "Vitor Soller" - assert repo.users[3].email == "dohype@vitin.com" - assert repo.users[3].user_id == 4 - assert repo.users[3].state == STATE.PENDING - - assert repo.user_counter == 4 - - def test_delete_user(self): - repo = UserRepositoryMock() - user = repo.delete_user(1) - assert user.name == "Bruno Soller" - assert user.email == "soller@soller.com" - assert user.user_id == 1 - assert user.state == STATE.APPROVED + updated_user = repo.update_user(user) + assert updated_user is not None + assert updated_user.role == "admin" + assert repo.users[0].role == "admin" - def test_delete_user_not_found(self): + def test_update_user_not_found(self): repo = UserRepositoryMock() + user = User(email="user@email.com") with pytest.raises(NoItemsFound): - user = repo.delete_user(69) + repo.update_user(user) - def test_update_user(self): + def test_delete_user(self): repo = UserRepositoryMock() - user = repo.update_user(1, "Bruno Guirão") + user_id = repo.get_all_user()[1].user_id + user = repo.delete_user(user_id) + assert user.email == "user@example.com" + assert user.role == "user" + assert len(repo.users) == 1 - assert user.name == "Bruno Guirão" - assert repo.users[0].name == "Bruno Guirão" - - def test_update_user_not_found(self): + def test_delete_user_not_found(self): repo = UserRepositoryMock() with pytest.raises(NoItemsFound): - user = repo.update_user(69, "Bruno Guirão") - - def test_get_users_counter(self): - repo = UserRepositoryMock() - - assert repo.get_user_counter() == 3 - + repo.delete_user(uuid.uuid4())