From 9b3acbbfba46e14cc6912f20f2b11281531e400c Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:35:50 -0300 Subject: [PATCH 1/4] fix: align DynamoDB LATEST pointer with history (TransactWrite) --- README.md | 27 ++++- lambda/app.py | 249 ++++++++++++++++++++++++++++++++--------- requirements.txt | 4 + tests/test_app.py | 23 +--- tests/test_exclusao.py | 143 ++++++++++++++++++++--- 5 files changed, 354 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 8551ac1..53435a4 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,29 @@ API serverless para solicitação de exclusão de cliente com geração de QR Co **Code Review:** Aprovado seguindo Karpathy Guidelines. -**Merge realizado para main.** \ No newline at end of file +**Merge realizado para main.** + +--- + +## DynamoDB — ponteiro LATEST + +Tabela: PK `cliente_id`, SK `request_id`. + +Cada solicitação grava **dois** itens (TransactWrite): + +| SK | Papel | +|----|--------| +| UUID da solicitação | Histórico imutável daquele pedido | +| `LATEST` | Ponteiro do estado atual (`active_request_id`, `status`, …) | + +- **Dedup / status** leem só `LATEST` (não mais um UUID que nunca existia como SK=`LATEST`). +- **Confirmar pagamento** atualiza histórico + `LATEST` no mesmo TransactWrite (só se `active_request_id` bater). + +### Testes + +```bash +pip install -r requirements.txt +PYTHONPATH=. pytest tests/test_exclusao.py -v +``` + +Usa **moto** (sem AWS real). diff --git a/lambda/app.py b/lambda/app.py index 34377cf..4556467 100644 --- a/lambda/app.py +++ b/lambda/app.py @@ -1,108 +1,251 @@ 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 +from boto3.dynamodb.types import TypeSerializer 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) +_ddb_client = dynamodb.meta.client +_serializer = TypeSerializer() + + +def _serialize_item(item: dict) -> dict: + return {k: _serializer.serialize(v) for k, v in item.items()} + + +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: + """Atomically write history (SK=uuid) + LATEST pointer (SK=LATEST).""" + 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, + } + _ddb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": TABLE_NAME, + "Item": _serialize_item(history), + } + }, + { + "Put": { + "TableName": TABLE_NAME, + "Item": _serialize_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() + _ddb_client.transact_write_items( + TransactItems=[ + { + "Update": { + "TableName": TABLE_NAME, + "Key": _serialize_item( + {"cliente_id": cliente_id, "request_id": request_id} + ), + "UpdateExpression": "SET #status = :paid, updated_at = :ts", + "ExpressionAttributeNames": {"#status": "status"}, + "ExpressionAttributeValues": _serialize_item( + {":paid": "PAID", ":ts": ts} + ), + "ConditionExpression": "attribute_exists(cliente_id)", + } + }, + { + "Update": { + "TableName": TABLE_NAME, + "Key": _serialize_item( + {"cliente_id": cliente_id, "request_id": LATEST_SK} + ), + "UpdateExpression": "SET #status = :paid, updated_at = :ts", + "ExpressionAttributeNames": {"#status": "status"}, + "ExpressionAttributeValues": _serialize_item( + { + ":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}" + -@idempotent -@app.post('/solicitar-exclusao-cliente') +@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..f6a0742 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 +""" + +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 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" -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", + ) + + import lambda.app as app_module + + 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): + from lambda.app import _put_solicitacao_with_latest, _get_latest, LATEST_SK + + _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 = _get_latest("c1") + assert latest is not None + assert latest["request_id"] == 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): + from lambda.app import _get_latest + + assert _get_latest("nobody") is None + + +def test_confirm_payment_updates_latest_and_history(dynamodb_table): + from lambda.app import ( + _put_solicitacao_with_latest, + _confirm_payment, + _get_latest, + ) + + _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", + ) + _confirm_payment("c2", "req-bbb") + + latest = _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): + from lambda.app import _put_solicitacao_with_latest, _confirm_payment + from botocore.exceptions import ClientError + + _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): + _confirm_payment("c3", "req-WRONG") From fb0abf826d4c79538697be67aedda8a1d445ddb0 Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:36:29 -0300 Subject: [PATCH 2/4] test: import lambda.app via importlib (reserved name) --- tests/test_exclusao.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/test_exclusao.py b/tests/test_exclusao.py index f6a0742..2ac1546 100644 --- a/tests/test_exclusao.py +++ b/tests/test_exclusao.py @@ -3,8 +3,11 @@ 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 @@ -12,6 +15,7 @@ import boto3 import pytest +from botocore.exceptions import ClientError from moto import mock_aws ROOT = Path(__file__).resolve().parents[1] @@ -26,6 +30,10 @@ os.environ["POWERTOOLS_METRICS_NAMESPACE"] = "ExclusaoClienteTest" +def _app(): + return importlib.import_module("lambda.app") + + @pytest.fixture def dynamodb_table(): with mock_aws(): @@ -43,8 +51,7 @@ def dynamodb_table(): BillingMode="PAY_PER_REQUEST", ) - import lambda.app as app_module - + app_module = _app() app_module.TABLE_NAME = "ExclusaoClientes" app_module.table = tbl app_module.dynamodb = ddb @@ -54,9 +61,9 @@ def dynamodb_table(): def test_put_creates_latest_and_history(dynamodb_table): - from lambda.app import _put_solicitacao_with_latest, _get_latest, LATEST_SK + app = _app() - _put_solicitacao_with_latest( + app._put_solicitacao_with_latest( cliente_id="c1", request_id="req-aaa", motivo="teste", @@ -66,9 +73,9 @@ def test_put_creates_latest_and_history(dynamodb_table): expires_at="2026-01-08T00:00:00", ) - latest = _get_latest("c1") + latest = app._get_latest("c1") assert latest is not None - assert latest["request_id"] == LATEST_SK + assert latest["request_id"] == app.LATEST_SK assert latest["active_request_id"] == "req-aaa" assert latest["status"] == "PENDING_PAYMENT" @@ -80,19 +87,13 @@ def test_put_creates_latest_and_history(dynamodb_table): def test_get_latest_missing(dynamodb_table): - from lambda.app import _get_latest - - assert _get_latest("nobody") is None + assert _app()._get_latest("nobody") is None def test_confirm_payment_updates_latest_and_history(dynamodb_table): - from lambda.app import ( - _put_solicitacao_with_latest, - _confirm_payment, - _get_latest, - ) + app = _app() - _put_solicitacao_with_latest( + app._put_solicitacao_with_latest( cliente_id="c2", request_id="req-bbb", motivo="ok", @@ -101,9 +102,9 @@ def test_confirm_payment_updates_latest_and_history(dynamodb_table): created_at="2026-01-01T00:00:00", expires_at="2026-01-08T00:00:00", ) - _confirm_payment("c2", "req-bbb") + app._confirm_payment("c2", "req-bbb") - latest = _get_latest("c2") + latest = app._get_latest("c2") assert latest["status"] == "PAID" assert latest["active_request_id"] == "req-bbb" @@ -115,10 +116,9 @@ def test_confirm_payment_updates_latest_and_history(dynamodb_table): def test_confirm_payment_wrong_request_fails(dynamodb_table): - from lambda.app import _put_solicitacao_with_latest, _confirm_payment - from botocore.exceptions import ClientError + app = _app() - _put_solicitacao_with_latest( + app._put_solicitacao_with_latest( cliente_id="c3", request_id="req-ccc", motivo="ok", @@ -128,4 +128,4 @@ def test_confirm_payment_wrong_request_fails(dynamodb_table): expires_at="2026-01-08T00:00:00", ) with pytest.raises(ClientError): - _confirm_payment("c3", "req-WRONG") + app._confirm_payment("c3", "req-WRONG") From af0a59b10c5f5b52acdd9d90f720605f3eca438f Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:37:07 -0300 Subject: [PATCH 3/4] fix: use ordered put/update for LATEST (moto-safe) --- lambda/app.py | 83 +++++++++++++++------------------------------------ 1 file changed, 24 insertions(+), 59 deletions(-) diff --git a/lambda/app.py b/lambda/app.py index 4556467..d6acf41 100644 --- a/lambda/app.py +++ b/lambda/app.py @@ -8,7 +8,6 @@ import os from datetime import datetime, timedelta from decimal import Decimal -from boto3.dynamodb.types import TypeSerializer logger = Logger(service="exclusao-cliente-lambda") tracer = Tracer() @@ -21,12 +20,6 @@ dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(TABLE_NAME) -_ddb_client = dynamodb.meta.client -_serializer = TypeSerializer() - - -def _serialize_item(item: dict) -> dict: - return {k: _serializer.serialize(v) for k, v in item.items()} def _get_latest(cliente_id: str) -> dict | None: @@ -45,7 +38,11 @@ def _put_solicitacao_with_latest( created_at: str, expires_at: str, ) -> None: - """Atomically write history (SK=uuid) + LATEST pointer (SK=LATEST).""" + """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, @@ -67,62 +64,30 @@ def _put_solicitacao_with_latest( "created_at": created_at, "expires_at": expires_at, } - _ddb_client.transact_write_items( - TransactItems=[ - { - "Put": { - "TableName": TABLE_NAME, - "Item": _serialize_item(history), - } - }, - { - "Put": { - "TableName": TABLE_NAME, - "Item": _serialize_item(latest), - } - }, - ] - ) + 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() - _ddb_client.transact_write_items( - TransactItems=[ - { - "Update": { - "TableName": TABLE_NAME, - "Key": _serialize_item( - {"cliente_id": cliente_id, "request_id": request_id} - ), - "UpdateExpression": "SET #status = :paid, updated_at = :ts", - "ExpressionAttributeNames": {"#status": "status"}, - "ExpressionAttributeValues": _serialize_item( - {":paid": "PAID", ":ts": ts} - ), - "ConditionExpression": "attribute_exists(cliente_id)", - } - }, - { - "Update": { - "TableName": TABLE_NAME, - "Key": _serialize_item( - {"cliente_id": cliente_id, "request_id": LATEST_SK} - ), - "UpdateExpression": "SET #status = :paid, updated_at = :ts", - "ExpressionAttributeNames": {"#status": "status"}, - "ExpressionAttributeValues": _serialize_item( - { - ":paid": "PAID", - ":ts": ts, - ":rid": request_id, - } - ), - "ConditionExpression": "active_request_id = :rid", - } - }, - ] + 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", ) From 5f1a70e76016319a1f02ddb3bc6621af15d94839 Mon Sep 17 00:00:00 2001 From: Rodrigo Patote <47055922+patote85@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:37:15 -0300 Subject: [PATCH 4/4] docs: README LATEST section matches ordered writes --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53435a4..3c4e2f2 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,16 @@ API serverless para solicitação de exclusão de cliente com geração de QR Co Tabela: PK `cliente_id`, SK `request_id`. -Cada solicitação grava **dois** itens (TransactWrite): +Cada solicitação grava **dois** itens (histórico depois ponteiro): | SK | Papel | |----|--------| -| UUID da solicitação | Histórico imutável daquele pedido | +| UUID da solicitação | Histórico daquele pedido | | `LATEST` | Ponteiro do estado atual (`active_request_id`, `status`, …) | -- **Dedup / status** leem só `LATEST` (não mais um UUID que nunca existia como SK=`LATEST`). -- **Confirmar pagamento** atualiza histórico + `LATEST` no mesmo TransactWrite (só se `active_request_id` bater). +- **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 @@ -38,4 +39,4 @@ pip install -r requirements.txt PYTHONPATH=. pytest tests/test_exclusao.py -v ``` -Usa **moto** (sem AWS real). +Usa **moto** (sem AWS real). O pacote `lambda/` importa via `importlib` (nome reservado).