Skip to content
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions CODE_REVIEW.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Empty file added src/__init__.py
Empty file.
111 changes: 77 additions & 34 deletions src/cart.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,105 @@
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
return

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())
2 changes: 1 addition & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
app = FastAPI()

@app.get("/")
def read_root():
def read_root() -> dict[str, str]:
return {"status": "ok"}
61 changes: 59 additions & 2 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added tests/__init__.py
Empty file.
Empty file added tests/integration/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions tests/integration/test_cart_flow.py
Original file line number Diff line number Diff line change
@@ -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")
Empty file added tests/unit/__init__.py
Empty file.
Loading