add test to checkout

This commit is contained in:
2025-12-16 22:25:34 -03:00
parent 086d3adcaf
commit b767aaaefd
13 changed files with 206 additions and 14 deletions

View File

@@ -47,6 +47,7 @@ app.include_router(users.emails, prefix='/users')
app.include_router(users.orgs, prefix='/users') app.include_router(users.orgs, prefix='/users')
app.include_router(users.password, prefix='/users') app.include_router(users.password, prefix='/users')
app.include_router(orders.router, prefix='/orders') app.include_router(orders.router, prefix='/orders')
app.include_router(orders.checkout, prefix='/orders')
app.include_router(orgs.add, prefix='/orgs') app.include_router(orgs.add, prefix='/orgs')
app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.billing, prefix='/orgs') app.include_router(orgs.billing, prefix='/orgs')
@@ -64,7 +65,7 @@ def health():
@app.exception_handler(ServiceError) @app.exception_handler(ServiceError)
def exc_error(exc: ServiceError): def exc_error(exc: ServiceError):
# logger.exception(exc) logger.exception(exc)
return JSONResponse( return JSONResponse(
body={ body={

View File

@@ -7,6 +7,10 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ORDER_TABLE from config import ORDER_TABLE
from .checkout import router as checkout
__all__ = ['checkout']
router = Router() router = Router()
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)

View File

@@ -0,0 +1,89 @@
import re
from decimal import Decimal
from http import HTTPStatus
from typing import Annotated, Literal
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
field_validator,
model_validator,
)
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import ORDER_TABLE
router = Router()
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
class User(BaseModel):
id: UUID4 | str
name: NameStr
class Address(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
postcode: str
address1: str
address2: str | None = None
neighborhood: str
city: str
state: str
@field_validator('postcode')
@classmethod
def ensure_numbers(cls, v: str) -> str:
return re.sub(r'\D', '', v)
class Item(BaseModel):
id: UUID4
name: str
unit_price: Decimal
quantity: int = 1
class Checkout(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str
email: EmailStr
address: Address
payment_method: Literal['PIX', 'CREDIT_CARD', 'BANK_SLIP', 'MANUAL']
items: tuple[Item, ...]
user: User | None = None
org_id: UUID4 | str | None = None
user_id: UUID4 | str | None = None
cnpj: CnpjStr | None = None
cpf: CpfStr | None = None
@model_validator(mode='after')
def verify_fields(self):
if not any([self.cnpj, self.cpf]):
raise ValueError('cnpj or cpf is required')
if self.cnpj is not None:
if self.org_id is None:
raise ValueError('org_id is missing')
if self.user is None:
raise ValueError('user is missing')
if self.cpf is not None and self.user_id is None:
raise ValueError('user_id is missing')
return self
@router.post('/')
def checkout(body: Annotated[Checkout, Body()]):
return JSONResponse(status_code=HTTPStatus.CREATED)

View File

@@ -11,7 +11,6 @@ from pydantic import FutureDatetime
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from middlewares.authentication_middleware import User as Authenticated
from ...enrollments.enroll import Enrollment, Org, Subscription, enroll_now from ...enrollments.enroll import Enrollment, Org, Subscription, enroll_now

View File

@@ -0,0 +1,91 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer
from ...conftest import HttpApiProxy, LambdaContext
def test_checkout(
app,
seeds,
http_api_proxy: HttpApiProxy,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/orders',
method=HTTPMethod.POST,
body={
'org_id': 'f6000f79-6e5c-49a0-952f-3bda330ef278',
'cnpj': '00000000000191',
'name': 'Branco do Brasil',
'email': 'sergio@somosbeta.com.br',
'payment_method': 'MANUAL',
'user': {
'id': '15bacf02-1535-4bee-9022-19d106fd7518',
'name': 'Sérgio R Siqueira',
},
'address': {
'city': 'Curitiba',
'postcode': '81280350',
'neighborhood': 'Cidade Industrial',
'address1': 'Rua Monsenhor Ivo Zanlorenzi',
'address2': 'nº 5190, ap 1802',
'state': 'PR',
},
'items': [
{
'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398',
'name': 'NR-35 Segurança nos Trabalhos em Altura',
'quantity': 2,
'unit_price': 119,
}
],
},
),
lambda_context,
)
print(r)
assert r['statusCode'] == HTTPStatus.CREATED
def test_checkout_from_user(
app,
seeds,
http_api_proxy: HttpApiProxy,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/orders',
method=HTTPMethod.POST,
body={
'user_id': '15bacf02-1535-4bee-9022-19d106fd7518',
'cpf': '07879819908',
'name': 'Sérgio R Siqueira',
'email': 'sergio@somosbeta.com.br',
'payment_method': 'MANUAL',
'address': {
'city': 'Curitiba',
'postcode': '81280350',
'neighborhood': 'Cidade Industrial',
'address1': 'Rua Monsenhor Ivo Zanlorenzi',
'address2': 'nº 5190, ap 1802',
'state': 'PR',
},
'items': [
{
'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398',
'name': 'NR-35 Segurança nos Trabalhos em Altura',
'quantity': 2,
'unit_price': 119,
}
],
},
),
lambda_context,
)
print(r)
assert r['statusCode'] == HTTPStatus.CREATED

View File

@@ -287,7 +287,7 @@ function List({ items, search }) {
{currency.format( {currency.format(
filtered filtered
?.filter((x) => 'course' in x) ?.filter((x) => 'course' in x)
.reduce((acc, { unit_price }) => acc + unit_price, 0) ?.reduce((acc, { unit_price }) => acc + unit_price, 0)
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -1,5 +1,5 @@
import { useRequest, useToggle } from 'ahooks' import { useRequest, useToggle } from 'ahooks'
import { CheckIcon, UserIcon, XIcon } from 'lucide-react' import { CheckIcon, UserIcon, XIcon, AlertTriangleIcon } from 'lucide-react'
import { formatCPF } from '@brazilian-utils/brazilian-utils' import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
@@ -109,6 +109,7 @@ export function AsyncCombobox({
key={id} key={id}
value={id} value={id}
className="cursor-pointer" className="cursor-pointer"
disabled={!cpf}
onSelect={() => { onSelect={() => {
onChange?.({ id, name, email, cpf }) onChange?.({ id, name, email, cpf })
set(false) set(false)
@@ -128,10 +129,16 @@ export function AsyncCombobox({
<li className="text-muted-foreground text-sm"> <li className="text-muted-foreground text-sm">
<Abbr>{email}</Abbr> <Abbr>{email}</Abbr>
</li> </li>
{cpf && (
{cpf ? (
<li className="text-muted-foreground text-sm"> <li className="text-muted-foreground text-sm">
{formatCPF(cpf)} {formatCPF(cpf)}
</li> </li>
) : (
<li className="flex gap-1 items-center text-red-400">
<AlertTriangleIcon className="text-red-400" />
Inelegível
</li>
)} )}
</ul> </ul>
</div> </div>

View File

@@ -96,8 +96,8 @@ export async function action({ params, request, context }: Route.ActionArgs) {
context context
}) })
const result = (await r.json()) as { sk: string } const data = (await r.json()) as { sk: string }
return redirect(`/${params.orgid}/enrollments/${result.sk}/submitted`) return redirect(`/${params.orgid}/enrollments/${data.sk}/submitted`)
} }
const emptyRow = { const emptyRow = {

View File

@@ -226,7 +226,7 @@ function Timeline({
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<ClockIcon /> <ClockIcon />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>Nenhum agendamento aqui</EmptyTitle> <EmptyTitle>Nenhum agendamento encontrado</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Ainda não agendamentos. Quando houver, eles aparecerão aqui. Ainda não agendamentos. Quando houver, eles aparecerão aqui.
</EmptyDescription> </EmptyDescription>

View File

@@ -79,11 +79,11 @@ export default function Route({}: Route.ComponentProps) {
switch (fetcher.data?.error?.type) { switch (fetcher.data?.error?.type) {
case 'RateLimitExceededError': case 'RateLimitExceededError':
toast.error('Seu limite diário de atualizações foi atingido.') toast.error('Seu limite diário de atualizações foi atingido.')
return
case 'CPFConflictError': case 'CPFConflictError':
setError('cpf', { message: 'CPF já está em uso' }) setError('cpf', { message: 'CPF já está em uso' })
return
} }
console.log(fetcher.data?.error)
}, [fetcher.data, setError]) }, [fetcher.data, setError])
return ( return (

View File

@@ -41,10 +41,10 @@ export async function loader({ params, context, request }: Route.ActionArgs) {
} }
const { items = [] } = (await r.json()) as { items: { sk: string }[] } const { items = [] } = (await r.json()) as { items: { sk: string }[] }
const orgs = items.map(({ sk, ...props }) => ({ const orgs = items.map(({ sk, ...props }) => {
...props, const [, id] = sk?.split('#')
id: sk?.split('#')[1] ?? null return { ...props, id }
})) })
const exists = orgs.some(({ id }) => id === params.orgid) const exists = orgs.some(({ id }) => id === params.orgid)
if (exists) { if (exists) {

View File

@@ -81,6 +81,7 @@ export default function Route({}: Route.ComponentProps) {
switch (fetcher.data?.error?.type) { switch (fetcher.data?.error?.type) {
case 'RateLimitExceededError': case 'RateLimitExceededError':
toast.error('Seu limite diário de atualizações foi atingido.') toast.error('Seu limite diário de atualizações foi atingido.')
return
} }
}, [fetcher.data]) }, [fetcher.data])