diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e91c588 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install dependencies + run: uv sync --all-groups + + - name: Lint (ruff) + run: uv run ruff check src/ tests/ + + - name: Type check (mypy) + run: uv run mypy --strict src/ + + - name: Tests + coverage + run: uv run pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=80 diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md new file mode 100644 index 0000000..814e8f1 --- /dev/null +++ b/CODE_REVIEW.md @@ -0,0 +1,31 @@ +# Code Review — cart.py & models.py + +## Falhas corrigidas + +### Segurança +- `price` e `quantity` agora rejeitam valores `<= 0` via `field_validator` do Pydantic +- Guarda explícita em `add_item` impede quantidade inválida antes de acessar a lista + +### Tipagem +- `float` → `Decimal` em `Product.price` (precisão financeira) +- `List[T]` → `list[T]` nativo (Python 3.12+) +- Retornos `-> None` explícitos em `add_item` e `remove_item` +- `calculate_total` e `calculate_total_with_discount` retornam `Decimal` + +### Estrutura (SOLID) +- `DiscountCalculator` extraído de `ShoppingCart` (SRP): regras de desconto isoladas e injetáveis +- Magic numbers (`500`, `1000`, `0.80`, `0.90`) substituídos por constantes nomeadas + +## Shift Left — documentado no PRD + +| ID | Prática | +|---|---| +| SL-001 | `mypy --strict` obrigatório no CI, bloqueia merge | +| SL-002 | Cobertura mínima 80% nos módulos de domínio, padrão AAA | +| SL-003 | Validação de contrato Pydantic na borda antes de integrações externas | + +## Arquivos alterados + +- `src/models.py` — validadores, `Decimal`, `frozen=True` +- `src/cart.py` — `DiscountCalculator`, constantes, tipagem corrigida +- `docs/PRD.md` — seção Shift Left adicionada diff --git a/docs/PRD.md b/docs/PRD.md index 9548000..363b616 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -15,3 +15,16 @@ O objetivo do projeto TechShop é desenvolver uma plataforma de e-commerce robus - **Segurança:** Todas as transações e dados do usuário devem ser criptografados. - **Desempenho:** A aplicação deve ter um tempo de resposta rápido, mesmo com um grande número de usuários simultâneos. - **Escalabilidade:** A arquitetura deve permitir o crescimento futuro da plataforma. + +## 4. Melhorias de Qualidade — Shift Left + +As práticas abaixo devem ser aplicadas **antes** da integração de qualquer código na branch principal, reduzindo o custo de correção de defeitos ao detectá-los o mais cedo possível no ciclo de desenvolvimento. + +### SL-001 — Verificação Estática de Tipos no CI +Todo código Python submetido via Pull Request deve passar pela verificação `mypy --strict` como etapa obrigatória do pipeline de CI/CD. Erros de tipo bloqueiam o merge. Isso elimina uma classe inteira de bugs de runtime antes da execução, sem custo de teste adicional. + +### SL-002 — Cobertura de Testes Unitários Obrigatória (padrão AAA) +Cada método público de domínio (ex.: `add_item`, `remove_item`, `calculate_total`, `calculate_total_with_discount`) deve ter ao menos um teste unitário seguindo o padrão Arrange-Act-Assert. O pipeline rejeita PRs com cobertura abaixo de 80% nos módulos de domínio (`src/cart.py`, `src/models.py`). Bugs de regra de negócio — como aceitar preço negativo ou quantidade zero — são identificados na fase de desenvolvimento, não em produção. + +### SL-003 — Validação de Contrato de Dados na Borda do Sistema +Todos os modelos de entrada e saída da API devem ser definidos com schemas Pydantic com validadores explícitos (`field_validator`) antes de qualquer integração com serviços externos (banco de dados, gateway de pagamento). Dados inválidos são rejeitados na camada de entrada com mensagem estruturada, impedindo que dados corrompidos propaguem para o domínio ou persistência. diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cart.py b/src/cart.py index 22d2a63..fecdb61 100644 --- a/src/cart.py +++ b/src/cart.py @@ -1,25 +1,71 @@ -from typing import List +from decimal import Decimal + from src.models import CartItem, Product -class ShoppingCart: - """ - Representa um carrinho de compras. +DISCOUNT_THRESHOLD_HIGH = Decimal("1000") +DISCOUNT_THRESHOLD_MID = Decimal("500") +DISCOUNT_RATE_HIGH = Decimal("0.80") +DISCOUNT_RATE_MID = Decimal("0.90") + + +class DiscountCalculator: + """Calcula descontos progressivos sobre um total de compra. + + Regras vigentes: + - Acima de R$ 1000: 20% de desconto. + - Acima de R$ 500: 10% de desconto. + - Abaixo ou igual a R$ 500: sem desconto. """ - def __init__(self): - """ - Inicializa um carrinho de compras com uma lista vazia de itens. + + def apply(self, total: Decimal) -> Decimal: + """Aplica o desconto cabível sobre o total informado. + + Args: + total: Valor bruto do carrinho. + + Returns: + Valor final após aplicação do desconto. """ - self.items: List[CartItem] = [] + if total > DISCOUNT_THRESHOLD_HIGH: + return total * DISCOUNT_RATE_HIGH + if total > DISCOUNT_THRESHOLD_MID: + return total * DISCOUNT_RATE_MID + return total + + +class ShoppingCart: + """Representa o carrinho de compras de um usuário. + + Attributes: + items: Lista de itens adicionados ao carrinho. + """ + + def __init__(self, discount_calculator: DiscountCalculator | None = None) -> None: + """Inicializa o carrinho com lista de itens vazia. - def add_item(self, product: Product, quantity: int): + Args: + discount_calculator: Estratégia de desconto a ser usada. + Se não informada, usa DiscountCalculator padrão. """ - Adiciona um produto ao carrinho. Se o produto já existir, - a quantidade é somada à existente. + self.items: list[CartItem] = [] + self._discount_calculator = discount_calculator or DiscountCalculator() + + def add_item(self, product: Product, quantity: int) -> None: + """Adiciona um produto ao carrinho. + + Se o produto já existir, incrementa a quantidade. A quantidade + informada deve ser maior que zero. Args: - product (Product): O produto a ser adicionado. - quantity (int): A quantidade a ser adicionada. + product: Produto a ser adicionado. + quantity: Quantidade a adicionar. Deve ser > 0. + + Raises: + ValueError: Se quantity for zero ou negativo. """ + if quantity <= 0: + raise ValueError("quantity must be greater than zero") + for item in self.items: if item.product.id == product.id: item.quantity += quantity @@ -27,36 +73,33 @@ def add_item(self, product: Product, quantity: int): self.items.append(CartItem(product=product, quantity=quantity)) - def remove_item(self, product_id: int): - """ - Remove um item do carrinho pelo ID do produto. + def remove_item(self, product_id: int) -> None: + """Remove o item correspondente ao product_id do carrinho. + + Não lança exceção se o produto não existir no carrinho. Args: - product_id (int): O ID do produto a ser removido. + product_id: Identificador do produto a remover. """ self.items = [item for item in self.items if item.product.id != product_id] - def calculate_total(self) -> float: - """ - Calcula o valor total dos itens no carrinho. + def calculate_total(self) -> Decimal: + """Calcula o valor bruto total dos itens no carrinho. Returns: - float: O valor total do carrinho. + Soma de (preço × quantidade) de todos os itens. """ - return sum(item.product.price * item.quantity for item in self.items) + return sum( + (item.product.price * item.quantity for item in self.items), + Decimal("0"), + ) - def calculate_total_with_discount(self) -> float: - """ - Calcula o valor total com desconto aplicado. - - 10% de desconto para compras acima de R$ 500. - - 20% de desconto para compras acima de R$ 1000. + def calculate_total_with_discount(self) -> Decimal: + """Calcula o valor total após aplicação de desconto progressivo. + + Delega a lógica de desconto ao DiscountCalculator injetado. Returns: - float: O valor total com o desconto aplicado. + Valor final com desconto aplicado. """ - total = self.calculate_total() - if total > 1000: - return total * 0.80 # 20% de desconto - if total > 500: - return total * 0.90 # 10% de desconto - return total + return self._discount_calculator.apply(self.calculate_total()) diff --git a/src/main.py b/src/main.py index 18abb38..584267a 100644 --- a/src/main.py +++ b/src/main.py @@ -3,5 +3,5 @@ app = FastAPI() @app.get("/") -def read_root(): +def read_root() -> dict[str, str]: return {"status": "ok"} diff --git a/src/models.py b/src/models.py index 38b930b..e27a62c 100644 --- a/src/models.py +++ b/src/models.py @@ -1,10 +1,67 @@ -from pydantic import BaseModel +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, field_validator + class Product(BaseModel): + """Representa um produto disponível na loja. + + Attributes: + id: Identificador único do produto. + name: Nome do produto. + price: Preço unitário em reais. Deve ser maior que zero. + """ + + model_config = ConfigDict(frozen=True) + id: int name: str - price: float + price: Decimal + + @field_validator("price") + @classmethod + def price_must_be_positive(cls, value: Decimal) -> Decimal: + """Garante que o preço seja estritamente positivo. + + Args: + value: Valor do preço a ser validado. + + Returns: + O valor validado. + + Raises: + ValueError: Se o preço for zero ou negativo. + """ + if value <= Decimal("0"): + raise ValueError("price must be greater than zero") + return value + class CartItem(BaseModel): + """Representa um item dentro do carrinho de compras. + + Attributes: + product: Produto associado ao item. + quantity: Quantidade do produto. Deve ser maior que zero. + """ + product: Product quantity: int + + @field_validator("quantity") + @classmethod + def quantity_must_be_positive(cls, value: int) -> int: + """Garante que a quantidade seja estritamente positiva. + + Args: + value: Valor da quantidade a ser validado. + + Returns: + O valor validado. + + Raises: + ValueError: Se a quantidade for zero ou negativa. + """ + if value <= 0: + raise ValueError("quantity must be greater than zero") + return value diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_cart_flow.py b/tests/integration/test_cart_flow.py new file mode 100644 index 0000000..649550a --- /dev/null +++ b/tests/integration/test_cart_flow.py @@ -0,0 +1,76 @@ +from decimal import Decimal + +from src.cart import ShoppingCart +from src.models import Product + + +def make_product(id: int, price: str, name: str = "Produto") -> Product: + return Product(id=id, name=name, price=Decimal(price)) + + +class TestCartCheckoutFlow: + def test_full_purchase_flow_with_discount(self): + """Fluxo completo: adiciona itens, remove um, aplica desconto.""" + # Arrange + cart = ShoppingCart() + notebook = make_product(id=1, price="900.00", name="Notebook") + mouse = make_product(id=2, price="150.00", name="Mouse") + teclado = make_product(id=3, price="200.00", name="Teclado") + + # Act + cart.add_item(notebook, quantity=1) + cart.add_item(mouse, quantity=2) + cart.add_item(teclado, quantity=1) + cart.remove_item(product_id=3) # remove teclado + + total = cart.calculate_total() + total_with_discount = cart.calculate_total_with_discount() + + # Assert — notebook(900) + mouse(2×150) = 1200 + assert total == Decimal("1200.00") + assert total_with_discount == Decimal("960.00") # 20% off + + def test_add_same_product_multiple_times_accumulates(self): + """Adicionar o mesmo produto várias vezes acumula a quantidade.""" + # Arrange + cart = ShoppingCart() + produto = make_product(id=1, price="100.00") + + # Act + cart.add_item(produto, quantity=1) + cart.add_item(produto, quantity=1) + cart.add_item(produto, quantity=1) + + # Assert + assert len(cart.items) == 1 + assert cart.items[0].quantity == 3 + assert cart.calculate_total() == Decimal("300.00") + + def test_cart_empty_after_removing_all_items(self): + """Carrinho fica vazio após remoção de todos os itens.""" + # Arrange + cart = ShoppingCart() + cart.add_item(make_product(id=1, price="50.00"), quantity=1) + cart.add_item(make_product(id=2, price="80.00"), quantity=2) + + # Act + cart.remove_item(product_id=1) + cart.remove_item(product_id=2) + + # Assert + assert len(cart.items) == 0 + assert cart.calculate_total() == Decimal("0") + assert cart.calculate_total_with_discount() == Decimal("0") + + def test_mid_tier_discount_flow(self): + """Compra entre R$500 e R$1000 recebe 10% de desconto.""" + # Arrange + cart = ShoppingCart() + produto = make_product(id=1, price="300.00") + + # Act + cart.add_item(produto, quantity=2) # total = 600 + + # Assert + assert cart.calculate_total() == Decimal("600.00") + assert cart.calculate_total_with_discount() == Decimal("540.00") diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cart.py b/tests/unit/test_cart.py new file mode 100644 index 0000000..5c6c353 --- /dev/null +++ b/tests/unit/test_cart.py @@ -0,0 +1,190 @@ +from decimal import Decimal + +import pytest + +from src.cart import DiscountCalculator, ShoppingCart +from src.models import Product + + +def make_product(id: int = 1, price: str = "100.00", name: str = "Produto") -> Product: + return Product(id=id, name=name, price=Decimal(price)) + + +class TestShoppingCartAddItem: + def test_add_new_item(self): + # Arrange + cart = ShoppingCart() + product = make_product() + + # Act + cart.add_item(product, quantity=2) + + # Assert + assert len(cart.items) == 1 + assert cart.items[0].quantity == 2 + + def test_add_existing_item_increments_quantity(self): + # Arrange + cart = ShoppingCart() + product = make_product() + cart.add_item(product, quantity=1) + + # Act + cart.add_item(product, quantity=3) + + # Assert + assert len(cart.items) == 1 + assert cart.items[0].quantity == 4 + + def test_add_different_products(self): + # Arrange + cart = ShoppingCart() + product_a = make_product(id=1) + product_b = make_product(id=2) + + # Act + cart.add_item(product_a, quantity=1) + cart.add_item(product_b, quantity=1) + + # Assert + assert len(cart.items) == 2 + + def test_add_item_zero_quantity_raises(self): + # Arrange + cart = ShoppingCart() + product = make_product() + + # Act / Assert + with pytest.raises(ValueError, match="quantity must be greater than zero"): + cart.add_item(product, quantity=0) + + def test_add_item_negative_quantity_raises(self): + # Arrange + cart = ShoppingCart() + product = make_product() + + # Act / Assert + with pytest.raises(ValueError, match="quantity must be greater than zero"): + cart.add_item(product, quantity=-1) + + +class TestShoppingCartRemoveItem: + def test_remove_existing_item(self): + # Arrange + cart = ShoppingCart() + product = make_product(id=1) + cart.add_item(product, quantity=1) + + # Act + cart.remove_item(product_id=1) + + # Assert + assert len(cart.items) == 0 + + def test_remove_nonexistent_item_does_not_raise(self): + # Arrange + cart = ShoppingCart() + + # Act / Assert + cart.remove_item(product_id=999) + assert len(cart.items) == 0 + + def test_remove_only_target_item(self): + # Arrange + cart = ShoppingCart() + cart.add_item(make_product(id=1), quantity=1) + cart.add_item(make_product(id=2), quantity=1) + + # Act + cart.remove_item(product_id=1) + + # Assert + assert len(cart.items) == 1 + assert cart.items[0].product.id == 2 + + +class TestShoppingCartCalculateTotal: + def test_total_empty_cart(self): + # Arrange + cart = ShoppingCart() + + # Act + total = cart.calculate_total() + + # Assert + assert total == Decimal("0") + + def test_total_single_item(self): + # Arrange + cart = ShoppingCart() + cart.add_item(make_product(price="50.00"), quantity=3) + + # Act + total = cart.calculate_total() + + # Assert + assert total == Decimal("150.00") + + def test_total_multiple_items(self): + # Arrange + cart = ShoppingCart() + cart.add_item(make_product(id=1, price="100.00"), quantity=2) + cart.add_item(make_product(id=2, price="50.00"), quantity=4) + + # Act + total = cart.calculate_total() + + # Assert + assert total == Decimal("400.00") + + +class TestDiscountCalculator: + def test_no_discount_below_500(self): + # Arrange + calc = DiscountCalculator() + + # Act + result = calc.apply(Decimal("499.99")) + + # Assert + assert result == Decimal("499.99") + + def test_10_percent_discount_above_500(self): + # Arrange + calc = DiscountCalculator() + + # Act + result = calc.apply(Decimal("600.00")) + + # Assert + assert result == Decimal("540.00") + + def test_20_percent_discount_above_1000(self): + # Arrange + calc = DiscountCalculator() + + # Act + result = calc.apply(Decimal("1200.00")) + + # Assert + assert result == Decimal("960.00") + + def test_boundary_exactly_500_no_discount(self): + # Arrange + calc = DiscountCalculator() + + # Act + result = calc.apply(Decimal("500.00")) + + # Assert + assert result == Decimal("500.00") + + def test_boundary_exactly_1000_mid_discount(self): + # Arrange + calc = DiscountCalculator() + + # Act + result = calc.apply(Decimal("1000.00")) + + # Assert + assert result == Decimal("900.00") diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..edb4a18 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,68 @@ +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from src.models import CartItem, Product + + +def make_product(**kwargs) -> Product: + defaults = {"id": 1, "name": "Teclado", "price": Decimal("100.00")} + return Product(**{**defaults, **kwargs}) + + +class TestProduct: + def test_valid_product(self): + # Arrange / Act + product = make_product() + + # Assert + assert product.id == 1 + assert product.name == "Teclado" + assert product.price == Decimal("100.00") + + def test_price_zero_raises(self): + # Arrange / Act / Assert + with pytest.raises(ValidationError, match="price must be greater than zero"): + make_product(price=Decimal("0")) + + def test_price_negative_raises(self): + # Arrange / Act / Assert + with pytest.raises(ValidationError, match="price must be greater than zero"): + make_product(price=Decimal("-10.00")) + + def test_product_is_immutable(self): + # Arrange + product = make_product() + + # Act / Assert + with pytest.raises(ValidationError): + product.price = Decimal("200.00") + + +class TestCartItem: + def test_valid_cart_item(self): + # Arrange + product = make_product() + + # Act + item = CartItem(product=product, quantity=3) + + # Assert + assert item.quantity == 3 + + def test_quantity_zero_raises(self): + # Arrange + product = make_product() + + # Act / Assert + with pytest.raises(ValidationError, match="quantity must be greater than zero"): + CartItem(product=product, quantity=0) + + def test_quantity_negative_raises(self): + # Arrange + product = make_product() + + # Act / Assert + with pytest.raises(ValidationError, match="quantity must be greater than zero"): + CartItem(product=product, quantity=-1)