From f3d884ed13ef596c42ac384d1a2e27b1ebfa5453 Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:38:47 -0300 Subject: [PATCH 1/3] fix: DynamoDB LATEST pointer + moto tests (rebased on main) --- README.md | 26 +++++ lambda/app.py | 214 +++++++++++++++++++++++++++++++---------- requirements.txt | 4 + tests/test_app.py | 23 +---- tests/test_exclusao.py | 143 ++++++++++++++++++++++++--- 5 files changed, 319 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index c0c7fdb..f9ada52 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,29 @@ API serverless para solicitação de exclusão de cliente com geração de QR Co ## Architecture Decision Records - [ADR-001 — Unificar exclusão Pix (CDK + Lambda)](docs/adr/ADR-001-unificar-exclusao-pix.md) — **Accepted** (CAB 2026-09-20). Este repo é o canônico; `aws-api-gateway-cdk` será arquivado após migrar o stack. Access pattern Dynamo: pointer `LATEST`. Auth (PR3): Cognito JWT. + +--- + +## DynamoDB — ponteiro LATEST + +Tabela: PK `cliente_id`, SK `request_id`. + +Cada solicitação grava **dois** itens (histórico depois ponteiro): + +| SK | Papel | +|----|--------| +| UUID da solicitação | Histórico daquele pedido | +| `LATEST` | Ponteiro do estado atual (`active_request_id`, `status`, …) | + +- **Dedup / status** leem só `LATEST` (antes o código gravava UUID e lia SK=`LATEST` — nunca achava). +- **Confirmar pagamento** atualiza histórico e depois `LATEST` (condition em `active_request_id`). +- Writes ordenados (`put`/`update`) de propósito neste PR — TransactWrite fica pra um follow-up se precisar atomicidade forte. + +### Testes + +```bash +pip install -r requirements.txt +PYTHONPATH=. pytest tests/test_exclusao.py -v +``` + +Usa **moto** (sem AWS real). O pacote `lambda/` importa via `importlib` (nome reservado). diff --git a/lambda/app.py b/lambda/app.py index 34377cf..d6acf41 100644 --- a/lambda/app.py +++ b/lambda/app.py @@ -1,108 +1,216 @@ from aws_lambda_powertools import Logger, Tracer, Metrics from aws_lambda_powertools.event_handler import APIGatewayHttpResolver from aws_lambda_powertools.utilities.typing import LambdaContext -from aws_lambda_powertools.utilities.idempotency import idempotent from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent import boto3 import json import uuid import os from datetime import datetime, timedelta +from decimal import Decimal logger = Logger(service="exclusao-cliente-lambda") tracer = Tracer() metrics = Metrics(namespace="ExclusaoCliente") app = APIGatewayHttpResolver() -TABLE_NAME = os.environ.get('TABLE_NAME', 'ExclusaoClientes') -PIX_FEE = float(os.environ.get('PIX_FEE', '50.00')) +TABLE_NAME = os.environ.get("TABLE_NAME", "ExclusaoClientes") +PIX_FEE = Decimal(str(os.environ.get("PIX_FEE", "50.00"))) +LATEST_SK = "LATEST" -dynamodb = boto3.resource('dynamodb') +dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(TABLE_NAME) -@idempotent -@app.post('/solicitar-exclusao-cliente') + +def _get_latest(cliente_id: str) -> dict | None: + """Read the LATEST pointer row for a cliente.""" + response = table.get_item(Key={"cliente_id": cliente_id, "request_id": LATEST_SK}) + return response.get("Item") + + +def _put_solicitacao_with_latest( + *, + cliente_id: str, + request_id: str, + motivo: str, + qr_code_data: str, + fee_amount: Decimal, + created_at: str, + expires_at: str, +) -> None: + """Write history (SK=uuid) then LATEST pointer (SK=LATEST). + + Ordered puts keep the PR small and moto-friendly. TransactWrite can + replace this later if we need stronger atomicity. + """ + history = { + "cliente_id": cliente_id, + "request_id": request_id, + "status": "PENDING_PAYMENT", + "motivo": motivo, + "qr_code_data": qr_code_data, + "fee_amount": fee_amount, + "created_at": created_at, + "expires_at": expires_at, + } + latest = { + "cliente_id": cliente_id, + "request_id": LATEST_SK, + "active_request_id": request_id, + "status": "PENDING_PAYMENT", + "motivo": motivo, + "qr_code_data": qr_code_data, + "fee_amount": fee_amount, + "created_at": created_at, + "expires_at": expires_at, + } + table.put_item(Item=history) + table.put_item(Item=latest) + + +def _confirm_payment(cliente_id: str, request_id: str) -> None: + """Mark history + LATEST as PAID when pointer matches request_id.""" + ts = datetime.utcnow().isoformat() + table.update_item( + Key={"cliente_id": cliente_id, "request_id": request_id}, + UpdateExpression="SET #status = :paid, updated_at = :ts", + ExpressionAttributeNames={"#status": "status"}, + ExpressionAttributeValues={":paid": "PAID", ":ts": ts}, + ConditionExpression="attribute_exists(cliente_id)", + ) + table.update_item( + Key={"cliente_id": cliente_id, "request_id": LATEST_SK}, + UpdateExpression="SET #status = :paid, updated_at = :ts", + ExpressionAttributeNames={"#status": "status"}, + ExpressionAttributeValues={ + ":paid": "PAID", + ":ts": ts, + ":rid": request_id, + }, + ConditionExpression="active_request_id = :rid", + ) + + +def generate_pix_qr_code(cliente_id: str, amount: Decimal, request_id: str) -> str: + return f"pix-emv-stub://{cliente_id}/{amount}/{request_id}" + + +@app.post("/solicitar-exclusao-cliente") @tracer.capture_method def solicitar_exclusao(event: APIGatewayProxyEvent): body = event.json_body or {} - cliente_id = body.get('cliente_id') - motivo = body.get('motivo', 'Não informado') + cliente_id = body.get("cliente_id") + motivo = body.get("motivo", "Não informado") if not cliente_id: - return {'statusCode': 400, 'body': json.dumps({'error': 'cliente_id is required'})} + return { + "statusCode": 400, + "body": json.dumps({"error": "cliente_id is required"}), + } + + existing = _get_latest(cliente_id) + if existing and existing.get("status") in ("PENDING_PAYMENT", "PAID"): + return { + "statusCode": 200, + "body": json.dumps( + { + "message": "Solicitação já existe", + "request_id": existing.get("active_request_id"), + "status": existing.get("status"), + } + ), + } request_id = str(uuid.uuid4()) timestamp = datetime.utcnow().isoformat() - - try: - response = table.get_item(Key={'cliente_id': cliente_id, 'request_id': 'LATEST'}) - if 'Item' in response: - existing = response['Item'] - if existing.get('status') in ['PENDING_PAYMENT', 'PAID']: - return {'statusCode': 200, 'body': json.dumps({'message': 'Solicitação já existe', 'request_id': existing.get('request_id'), 'status': existing.get('status')})} - except Exception: - pass - + expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat() qr_code_data = generate_pix_qr_code(cliente_id, PIX_FEE, request_id) - item = { - 'cliente_id': cliente_id, - 'request_id': request_id, - 'status': 'PENDING_PAYMENT', - 'motivo': motivo, - 'qr_code_data': qr_code_data, - 'fee_amount': PIX_FEE, - 'created_at': timestamp, - 'expires_at': (datetime.utcnow() + timedelta(days=7)).isoformat() - } - table.put_item(Item=item) + _put_solicitacao_with_latest( + cliente_id=cliente_id, + request_id=request_id, + motivo=motivo, + qr_code_data=qr_code_data, + fee_amount=PIX_FEE, + created_at=timestamp, + expires_at=expires_at, + ) metrics.add_metric(name="exclusao_solicitada", unit="Count", value=1) logger.info("Solicitação criada", extra={"cliente_id": cliente_id}) - return {'statusCode': 201, 'body': json.dumps({'message': 'Solicitação registrada', 'request_id': request_id, 'qr_code': qr_code_data, 'fee': PIX_FEE})} + return { + "statusCode": 201, + "body": json.dumps( + { + "message": "Solicitação registrada", + "request_id": request_id, + "qr_code": qr_code_data, + "fee": float(PIX_FEE), + } + ), + } -def generate_pix_qr_code(cliente_id: str, amount: float, request_id: str) -> str: - return f"pix-emv-stub://{cliente_id}/{amount}/{request_id}" -@app.get('/health') +@app.get("/health") @tracer.capture_method def health_check(): try: table.meta.client.describe_table(TableName=TABLE_NAME) - return {'statusCode': 200, 'body': json.dumps({'status': 'healthy'})} + return {"statusCode": 200, "body": json.dumps({"status": "healthy"})} except Exception as e: - return {'statusCode': 503, 'body': json.dumps({'status': 'unhealthy', 'error': str(e)})} + return { + "statusCode": 503, + "body": json.dumps({"status": "unhealthy", "error": str(e)}), + } -@app.get('/status-exclusao/{cliente_id}') + +@app.get("/status-exclusao/{cliente_id}") @tracer.capture_method def get_status(cliente_id: str): try: - response = table.get_item(Key={'cliente_id': cliente_id, 'request_id': 'LATEST'}) - if 'Item' in response: - item = response['Item'] - return {'statusCode': 200, 'body': json.dumps({'cliente_id': cliente_id, 'status': item.get('status'), 'request_id': item.get('request_id')})} - return {'statusCode': 404, 'body': json.dumps({'error': 'Não encontrado'})} + item = _get_latest(cliente_id) + if item: + return { + "statusCode": 200, + "body": json.dumps( + { + "cliente_id": cliente_id, + "status": item.get("status"), + "request_id": item.get("active_request_id"), + } + ), + } + return {"statusCode": 404, "body": json.dumps({"error": "Não encontrado"})} except Exception as e: - return {'statusCode': 500, 'body': json.dumps({'error': str(e)})} + return {"statusCode": 500, "body": json.dumps({"error": str(e)})} + -@app.post('/confirmar-pagamento') +@app.post("/confirmar-pagamento") @tracer.capture_method def confirmar_pagamento(event: APIGatewayProxyEvent): body = event.json_body or {} + cliente_id = body.get("cliente_id") + request_id = body.get("request_id") + if not cliente_id or not request_id: + return { + "statusCode": 400, + "body": json.dumps( + {"error": "cliente_id and request_id are required"} + ), + } try: - table.update_item( - Key={'cliente_id': body.get('cliente_id'), 'request_id': body.get('request_id')}, - UpdateExpression='SET #status = :paid, updated_at = :ts', - ExpressionAttributeNames={'#status': 'status'}, - ExpressionAttributeValues={':paid': 'PAID', ':ts': datetime.utcnow().isoformat()} - ) + _confirm_payment(cliente_id, request_id) metrics.add_metric(name="exclusao_paga", unit="Count", value=1) - return {'statusCode': 200, 'body': json.dumps({'message': 'Pagamento confirmado'})} + return { + "statusCode": 200, + "body": json.dumps({"message": "Pagamento confirmado"}), + } except Exception as e: - return {'statusCode': 500, 'body': json.dumps({'error': str(e)})} + return {"statusCode": 500, "body": json.dumps({"error": str(e)})} + @logger.inject_lambda_context(correlation_id_path="requestContext.requestId") @tracer.capture_lambda_handler def lambda_handler(event: dict, context: LambdaContext) -> dict: - return app.resolve(event, context) \ No newline at end of file + return app.resolve(event, context) diff --git a/requirements.txt b/requirements.txt index b8e2318..78debed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ aws-lambda-powertools[all] boto3 qrcode # for real QR if needed + +# Local / CI testing +pytest>=8.0.0 +moto[dynamodb]>=5.0.0 diff --git a/tests/test_app.py b/tests/test_app.py index d884e30..d436dcf 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,22 +1 @@ -import json -import pytest -from unittest.mock import patch, MagicMock -from lambda.app import app, hello, save_item - -mock_table = MagicMock() -mock_table.put_item.return_value = {} - -@pytest.fixture -def mock_dynamodb(): - with patch('lambda.app.table', mock_table): - yield mock_table - -def test_hello_default(): - result = hello() - assert result['message'] == 'Hello World from Lambda!' - -def test_save_item_success(mock_dynamodb): - item = {'id': '123', 'name': 'Test'} - result = save_item(item) - assert result['status'] == 'saved' - mock_dynamodb.put_item.assert_called_once_with(Item=item) \ No newline at end of file +"""Legacy stub tests removed — see tests/test_exclusao.py (moto + LATEST).""" diff --git a/tests/test_exclusao.py b/tests/test_exclusao.py index 3f21799..2ac1546 100644 --- a/tests/test_exclusao.py +++ b/tests/test_exclusao.py @@ -1,20 +1,131 @@ -import json +""" +Moto tests for DynamoDB LATEST pointer + history alignment. + + pip install -r requirements.txt + PYTHONPATH=. pytest tests/test_exclusao.py -v + +Note: package dir is named `lambda` (reserved word) — import via importlib. +""" + +import importlib +import os +import sys +from decimal import Decimal +from pathlib import Path + +import boto3 import pytest -from unittest.mock import patch, MagicMock -from lambda.app import app, solicitar_exclusao, get_status +from botocore.exceptions import ClientError +from moto import mock_aws + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +os.environ["TABLE_NAME"] = "ExclusaoClientes" +os.environ["PIX_FEE"] = "50.00" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["POWERTOOLS_TRACE_DISABLED"] = "true" +os.environ["POWERTOOLS_METRICS_NAMESPACE"] = "ExclusaoClienteTest" + + +def _app(): + return importlib.import_module("lambda.app") -mock_table = MagicMock() @pytest.fixture -def mock_dynamodb(): - with patch('lambda.app.table', mock_table): - yield - -def test_solicitar_exclusao(mock_dynamodb): - event = {'httpMethod': 'POST', 'path': '/solicitar-exclusao-cliente', 'body': json.dumps({'cliente_id': '123', 'motivo': 'Test'})} - # Simplified test - in real use moto or full mock - # assert response has qr_code etc. - pass # Expand with full mocks - -def test_get_status(mock_dynamodb): - pass \ No newline at end of file +def dynamodb_table(): + with mock_aws(): + ddb = boto3.resource("dynamodb", region_name="us-east-1") + tbl = ddb.create_table( + TableName="ExclusaoClientes", + KeySchema=[ + {"AttributeName": "cliente_id", "KeyType": "HASH"}, + {"AttributeName": "request_id", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "cliente_id", "AttributeType": "S"}, + {"AttributeName": "request_id", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + + app_module = _app() + app_module.TABLE_NAME = "ExclusaoClientes" + app_module.table = tbl + app_module.dynamodb = ddb + app_module._ddb_client = ddb.meta.client + + yield tbl + + +def test_put_creates_latest_and_history(dynamodb_table): + app = _app() + + app._put_solicitacao_with_latest( + cliente_id="c1", + request_id="req-aaa", + motivo="teste", + qr_code_data="pix-stub", + fee_amount=Decimal("50.00"), + created_at="2026-01-01T00:00:00", + expires_at="2026-01-08T00:00:00", + ) + + latest = app._get_latest("c1") + assert latest is not None + assert latest["request_id"] == app.LATEST_SK + assert latest["active_request_id"] == "req-aaa" + assert latest["status"] == "PENDING_PAYMENT" + + history = dynamodb_table.get_item( + Key={"cliente_id": "c1", "request_id": "req-aaa"} + )["Item"] + assert history["status"] == "PENDING_PAYMENT" + assert history["motivo"] == "teste" + + +def test_get_latest_missing(dynamodb_table): + assert _app()._get_latest("nobody") is None + + +def test_confirm_payment_updates_latest_and_history(dynamodb_table): + app = _app() + + app._put_solicitacao_with_latest( + cliente_id="c2", + request_id="req-bbb", + motivo="ok", + qr_code_data="pix", + fee_amount=Decimal("50.00"), + created_at="2026-01-01T00:00:00", + expires_at="2026-01-08T00:00:00", + ) + app._confirm_payment("c2", "req-bbb") + + latest = app._get_latest("c2") + assert latest["status"] == "PAID" + assert latest["active_request_id"] == "req-bbb" + + history = dynamodb_table.get_item( + Key={"cliente_id": "c2", "request_id": "req-bbb"} + )["Item"] + assert history["status"] == "PAID" + assert "updated_at" in history + + +def test_confirm_payment_wrong_request_fails(dynamodb_table): + app = _app() + + app._put_solicitacao_with_latest( + cliente_id="c3", + request_id="req-ccc", + motivo="ok", + qr_code_data="pix", + fee_amount=Decimal("50.00"), + created_at="2026-01-01T00:00:00", + expires_at="2026-01-08T00:00:00", + ) + with pytest.raises(ClientError): + app._confirm_payment("c3", "req-WRONG") From b71f1e30a731065ae8e9784ea22b5bc4deb6c452 Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:40:20 -0300 Subject: [PATCH 2/3] ci: add pytest+moto workflow (Python 3.12) --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e69d223 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run pytest + # moto mocks DynamoDB — no AWS credentials required + run: PYTHONPATH=. pytest tests/test_exclusao.py -v diff --git a/README.md b/README.md index f9ada52..0411cdf 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,5 @@ PYTHONPATH=. pytest tests/test_exclusao.py -v ``` Usa **moto** (sem AWS real). O pacote `lambda/` importa via `importlib` (nome reservado). + +CI (GitHub Actions): mesmo comando em Python 3.12 em todo PR/`push` na `main` — sem credenciais AWS no runner. From 483b77f115fb6f96ff2120f9d9a10b2b943b5432 Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:41:29 -0300 Subject: [PATCH 3/3] ci: remove stub ci-cd.yml (was only a comment, always failed) --- .github/workflows/ci-cd.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/workflows/ci-cd.yml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index f75d9fd..0000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1 +0,0 @@ -# Pipeline atualizada \ No newline at end of file