add test to checkout
This commit is contained in:
@@ -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={
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
89
api.saladeaula.digital/app/routes/orders/checkout.py
Normal file
89
api.saladeaula.digital/app/routes/orders/checkout.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
91
api.saladeaula.digital/tests/routes/orders/test_checkout.py
Normal file
91
api.saladeaula.digital/tests/routes/orders/test_checkout.py
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 há agendamentos. Quando houver, eles aparecerão aqui.
|
Ainda não há agendamentos. Quando houver, eles aparecerão aqui.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user