🐛 fix(identity): validar CPF informado e bloquear vinculo duplicado no mock

- passa a aceitar apenas CPFs validos informados pelo usuario na hidratacao mock, mantendo score, limite e restricoes como dados derivados automaticamente do documento informado

- impede que o mesmo CPF fique vinculado a dois usuarios diferentes, retornando erro de dominio claro no pedido e preservando o fluxo aberto para que o cliente informe outro CPF

- atualiza o seed local para gerar CPFs validos e deterministas, alinhando a base mock aos testes manuais e evitando registros incoerentes no ambiente de validacao

- amplia a cobertura de regressao para seed, order service e fluxo conversacional de compra com CPF invalido ou ja vinculado
main
parent d1bd972f57
commit 876b0dd2d1

@ -60,9 +60,18 @@ VEHICLE_PRICE_BANDS = (
) )
def _cpf_check_digit(base_digits: str) -> str:
total = sum(int(digit) * weight for digit, weight in zip(base_digits, range(len(base_digits) + 1, 1, -1)))
remainder = 11 - (total % 11)
return "0" if remainder >= 10 else str(remainder)
def _cpf_from_index(index: int) -> str: def _cpf_from_index(index: int) -> str:
"""Gera um CPF numerico deterministico de 11 digitos a partir do indice.""" """Gera um CPF valido e deterministico de 11 digitos a partir do indice."""
return str(10_000_000_000 + index).zfill(11) base_digits = f"{100_000_000 + index:09d}"[-9:]
first_digit = _cpf_check_digit(base_digits)
second_digit = _cpf_check_digit(base_digits + first_digit)
return f"{base_digits}{first_digit}{second_digit}"
def _vehicle_price_from_index(index: int, rng: random.Random) -> float: def _vehicle_price_from_index(index: int, rng: random.Random) -> float:
@ -155,3 +164,4 @@ def seed_mock_data() -> None:
db.commit() db.commit()
finally: finally:
db.close() db.close()

@ -226,7 +226,25 @@ async def realizar_pedido(
valor_veiculo = float(vehicle.preco) valor_veiculo = float(vehicle.preco)
modelo_veiculo = str(vehicle.modelo) modelo_veiculo = str(vehicle.modelo)
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) try:
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
except ValueError as exc:
if str(exc) == "cpf_already_linked":
raise_tool_http_error(
status_code=409,
code="cpf_already_linked",
message="Este CPF ja esta vinculado a outro usuario.",
retryable=True,
field="cpf",
)
raise_tool_http_error(
status_code=400,
code="invalid_cpf",
message="CPF invalido para realizar o pedido.",
retryable=True,
field="cpf",
)
avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo) avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo)
if not avaliacao.get("aprovado"): if not avaliacao.get("aprovado"):
raise_tool_http_error( raise_tool_http_error(
@ -276,10 +294,6 @@ async def realizar_pedido(
) )
numero_pedido = f"PED-{utc_now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}" numero_pedido = f"PED-{utc_now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}"
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
if user and user.cpf != cpf_norm:
user.cpf = cpf_norm
pedido = Order( pedido = Order(
numero_pedido=numero_pedido, numero_pedido=numero_pedido,
@ -338,3 +352,5 @@ async def realizar_pedido(
finally: finally:
_release_vehicle_reservation_lock(db, reservation_lock_name) _release_vehicle_reservation_lock(db, reservation_lock_name)
db.close() db.close()

@ -992,7 +992,7 @@ class OrderFlowMixin:
cpf=str(cpf_value), cpf=str(cpf_value),
user_id=user_id, user_id=user_id,
) )
except ValueError: except ValueError as exc:
draft["payload"].pop("cpf", None) draft["payload"].pop("cpf", None)
self._set_order_flow_entry( self._set_order_flow_entry(
"pending_order_drafts", "pending_order_drafts",
@ -1001,6 +1001,8 @@ class OrderFlowMixin:
draft, draft,
active_task="order_create", active_task="order_create",
) )
if str(exc) == "cpf_already_linked":
return "Este CPF ja esta vinculado a outro usuario. Pode me informar um CPF diferente?"
return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?"
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
@ -1189,3 +1191,4 @@ class OrderFlowMixin:
return self._fallback_format_tool_result("cancelar_pedido", tool_result) return self._fallback_format_tool_result("cancelar_pedido", tool_result)

@ -2,6 +2,7 @@ import hashlib
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, User from app.db.mock_models import Customer, User
from app.services.orchestration.technical_normalizer import is_valid_cpf, normalize_cpf
# Helpers para criar clientes ficticios deterministas a partir do CPF. # Helpers para criar clientes ficticios deterministas a partir do CPF.
@ -19,8 +20,6 @@ MOCK_CUSTOMER_NAMES = [
] ]
def _normalize_cpf(value: str) -> str:
return "".join(char for char in str(value or "") if char.isdigit())
def _stable_int(seed_text: str) -> int: def _stable_int(seed_text: str) -> int:
@ -50,8 +49,8 @@ async def hydrate_mock_customer_from_cpf(
Esta funcao existe apenas para a fase local/mock. Quando a API real entrar, Esta funcao existe apenas para a fase local/mock. Quando a API real entrar,
ela deve ser substituida/removida. ela deve ser substituida/removida.
""" """
cpf_norm = _normalize_cpf(cpf) cpf_norm = normalize_cpf(cpf)
if len(cpf_norm) != 11: if not cpf_norm or not is_valid_cpf(cpf_norm):
raise ValueError("CPF invalido para hidratacao mock.") raise ValueError("CPF invalido para hidratacao mock.")
db = SessionMockLocal() db = SessionMockLocal()
@ -66,6 +65,14 @@ async def hydrate_mock_customer_from_cpf(
linked_user = False linked_user = False
user = None user = None
if user_id is not None: if user_id is not None:
conflicting_user = (
db.query(User)
.filter(User.cpf == cpf_norm, User.id != user_id)
.first()
)
if conflicting_user:
raise ValueError("cpf_already_linked")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user and user.cpf != cpf_norm: if user and user.cpf != cpf_norm:
user.cpf = cpf_norm user.cpf = cpf_norm
@ -87,3 +94,4 @@ async def hydrate_mock_customer_from_cpf(
} }
finally: finally:
db.close() db.close()

@ -703,6 +703,49 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(registry.calls, []) self.assertEqual(registry.calls, [])
self.assertIsNotNone(state.get_entry("pending_order_drafts", 10)) self.assertIsNotNone(state.get_entry("pending_order_drafts", 10))
async def test_order_flow_rejects_cpf_linked_to_other_user_without_resetting_vehicle_choice(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"vehicle_id": 1},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {},
"shared_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0}
],
"selected_vehicle": {"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
raise ValueError("cpf_already_linked")
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="12345678909",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertEqual(response, "Este CPF ja esta vinculado a outro usuario. Pode me informar um CPF diferente?")
self.assertEqual(registry.calls, [])
self.assertIsNotNone(state.get_entry("pending_order_drafts", 10))
self.assertEqual(state.get_user_context(10)["selected_vehicle"]["id"], 1)
async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self): async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -2876,3 +2919,4 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from app.db import mock_seed as mock_seed_module from app.db import mock_seed as mock_seed_module
from app.db.mock_database import MockBase from app.db.mock_database import MockBase
from app.db.mock_models import Customer, Order, Vehicle from app.db.mock_models import Customer, Order, Vehicle
from app.services.orchestration.technical_normalizer import is_valid_cpf
class MockSeedDataTests(unittest.TestCase): class MockSeedDataTests(unittest.TestCase):
@ -36,6 +37,9 @@ class MockSeedDataTests(unittest.TestCase):
db.query(Vehicle).filter(Vehicle.preco <= 70000).count(), db.query(Vehicle).filter(Vehicle.preco <= 70000).count(),
50, 50,
) )
sample_cpfs = [row.cpf for row in db.query(Customer).order_by(Customer.id).limit(5).all()]
self.assertTrue(sample_cpfs)
self.assertTrue(all(is_valid_cpf(cpf) for cpf in sample_cpfs))
finally: finally:
db.close() db.close()
@ -86,3 +90,4 @@ class MockSeedDataTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -120,6 +120,31 @@ class OrderServiceReservationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session.added, []) self.assertEqual(session.added, [])
self.assertTrue(session.closed) self.assertTrue(session.closed)
async def test_realizar_pedido_rejects_cpf_linked_to_other_user(self):
vehicle = Vehicle(id=8, modelo="Toyota Corolla 2024", categoria="suv", preco=76087.0)
session = FakeSession(vehicle=vehicle)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
raise ValueError("cpf_already_linked")
async def fake_validar_cliente_venda(cpf: str, valor_veiculo: float):
raise AssertionError("nao deveria consultar credito quando o CPF ja pertence a outro usuario")
with patch.object(order_service, "SessionMockLocal", return_value=session), patch.object(
order_service,
"hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
), patch.object(
order_service,
"validar_cliente_venda",
new=fake_validar_cliente_venda,
):
with self.assertRaises(HTTPException) as ctx:
await order_service.realizar_pedido(cpf="123.456.789-09", vehicle_id=8, user_id=99)
self.assertEqual(ctx.exception.status_code, 409)
self.assertEqual(ctx.exception.detail["code"], "cpf_already_linked")
self.assertTrue(session.closed)
async def test_realizar_pedido_uses_locked_vehicle_before_persisting_order(self): async def test_realizar_pedido_uses_locked_vehicle_before_persisting_order(self):
vehicle = Vehicle(id=8, modelo="Toyota Corolla 2024", categoria="suv", preco=76087.0) vehicle = Vehicle(id=8, modelo="Toyota Corolla 2024", categoria="suv", preco=76087.0)
session = FakeSession(vehicle=vehicle) session = FakeSession(vehicle=vehicle)
@ -161,3 +186,4 @@ class OrderServiceReservationTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

Loading…
Cancel
Save