Implement localization for API responses and error messages

- Added localization support for API responses in English and Spanish.
- Introduced a middleware to set the API locale based on the Accept-Language header.
- Updated various exception messages and validation responses to use localized strings.
- Created new language files for English and Spanish translations.
- Refactored existing code to replace hardcoded messages with localized strings.
- Added tests to verify localization functionality and response correctness.
This commit is contained in:
2026-07-28 10:19:39 -03:00
parent 754aeebe3a
commit ff9d7728e8
34 changed files with 654 additions and 114 deletions

View File

@@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file

View File

@@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller
);
return response()->json([
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}

View File

@@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller
);
$response = response()->json([
'message' => 'Sesion iniciada correctamente.',
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $authentication['token'],
'token_type' => 'Bearer',
'user' => UserResource::make($authentication['user']),

View File

@@ -28,7 +28,7 @@ class LoginController extends Controller
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.',
'email' => __('api.auth.invalid_credentials'),
]);
}
@@ -50,7 +50,8 @@ class LoginController extends Controller
);
$response = response()->json([
'message' => 'Sesion iniciada correctamente.',
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),

View File

@@ -23,7 +23,8 @@ class LogoutController extends Controller
}
return response()->json([
'message' => 'Sesion cerrada correctamente.',
'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
]);
}
}

View File

@@ -12,15 +12,17 @@ class RegisterController extends Controller
{
public function __construct(
protected RegisterUserService $registerUserService,
) {
}
) {}
public function __invoke(RegisterUserRequest $request): JsonResponse
{
$user = $this->registerUserService->register($request->validated());
return UserResource::make($user)
->additional(['message' => 'Usuario registrado correctamente.'])
->additional([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->response()
->setStatusCode(201);
}

View File

@@ -28,12 +28,13 @@ class ResetPasswordController extends Controller
$data['password'],
)) {
throw ValidationException::withMessages([
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'codigo' => __('api.auth.password_reset_invalid'),
]);
}
return response()->json([
'message' => 'Contraseña modificada correctamente.',
'code' => 'auth.password_updated',
'message' => __('api.auth.password_updated'),
'status' => ResetPasswordAttempt::STATUS_USED,
]);
}

View File

@@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller
$data['codigo'],
)) {
throw ValidationException::withMessages([
'codigo' => 'El código ingresado es inválido.',
'codigo' => __('api.auth.reset_code_invalid'),
]);
}
return response()->json([
'message' => 'Código validado correctamente.',
'code' => 'auth.reset_code_valid',
'message' => __('api.auth.reset_code_valid'),
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
}

View File

@@ -25,7 +25,7 @@ class GoogleAuthService
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => 'El tenant o la URL de retorno no son validos.',
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
@@ -50,13 +50,13 @@ class GoogleAuthService
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
abort(400, __('api.auth.request_expired'));
}
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
abort(400, 'La URL de retorno no es valida.');
abort(400, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
@@ -88,13 +88,13 @@ class GoogleAuthService
if (! $authentication) {
throw ValidationException::withMessages([
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
'oauth_code' => __('api.auth.oauth_code_expired'),
]);
}
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.',
'tenant_codigo' => __('api.auth.tenant_mismatch'),
]);
}
@@ -112,7 +112,7 @@ class GoogleAuthService
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([
'google' => 'Google no devolvio una identidad valida.',
'google' => __('api.auth.google_email_required'),
]);
}
@@ -120,7 +120,7 @@ class GoogleAuthService
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => 'La cuenta de Google debe tener el email verificado.',
'google' => __('api.auth.google_email_unverified'),
]);
}

View File

@@ -36,7 +36,10 @@ class CartController extends Controller
);
$response = CartResource::make($result['cart'])
->additional(['message' => 'Producto agregado al carrito.'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
@@ -58,13 +61,19 @@ class CartController extends Controller
$cartItem->getKey(),
(int) $request->validated('cantidad'),
)
)->additional(['message' => 'Cantidad de producto actualizada.']);
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']);
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -79,7 +79,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -91,7 +91,7 @@ class Cart extends Model
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
@@ -123,7 +123,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -146,7 +146,7 @@ class Cart extends Model
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
@@ -209,13 +209,13 @@ class Cart extends Model
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => 'Un bundle no admite una variante.',
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => 'El bundle no tiene componentes.',
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
@@ -225,7 +225,7 @@ class Cart extends Model
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => 'Debe seleccionar una variante para este ítem.',
'variant_id' => __('api.cart.variant_required'),
]);
}

View File

@@ -45,7 +45,7 @@ class CatalogService
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => ['Un item standard no puede tener componentes.'],
'components' => [__('api.catalog.standard_with_components')],
]);
}
@@ -265,7 +265,7 @@ class CatalogService
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"components.{$index}" => ['El componente esta duplicado.'],
"components.{$index}" => [__('api.catalog.duplicate_component')],
]);
}
$seen[$key] = true;
@@ -278,7 +278,7 @@ class CatalogService
if ($componentItem === null) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El item no pertenece al tenant del bundle.',
__('api.catalog.component_wrong_tenant'),
],
]);
}
@@ -286,7 +286,7 @@ class CatalogService
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El componente debe ser un item standard distinto del bundle.',
__('api.catalog.invalid_component'),
],
]);
}
@@ -295,7 +295,7 @@ class CatalogService
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'Debe seleccionar una variante para este componente.',
__('api.catalog.component_variant_required'),
],
]);
}
@@ -303,7 +303,7 @@ class CatalogService
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'El componente con inventario directo no admite una variante.',
__('api.catalog.component_variant_forbidden'),
],
]);
}
@@ -311,7 +311,7 @@ class CatalogService
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'La variante no pertenece al componente indicado.',
__('api.catalog.component_variant_invalid'),
],
]);
}
@@ -333,7 +333,7 @@ class CatalogService
{
if ($components === []) {
throw ValidationException::withMessages([
'components' => ['Un bundle debe tener al menos un componente.'],
'components' => [__('api.catalog.bundle_component_required')],
]);
}
@@ -348,7 +348,7 @@ class CatalogService
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => ["{$field} no se admite para un bundle."],
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]);
}
}
@@ -376,7 +376,7 @@ class CatalogService
if ($attachment === null) {
throw ValidationException::withMessages([
$validationKey => ['El attachment indicado no existe.'],
$validationKey => [__('api.catalog.attachment_not_found')],
]);
}
@@ -408,7 +408,7 @@ class CatalogService
if ($attribute === null) {
throw ValidationException::withMessages([
'attribute_codes' => [
"El atributo {$attributeCode} no existe para el tenant del ítem.",
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
],
]);
}
@@ -442,7 +442,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
"variants.{$index}.inventory" => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}
@@ -463,7 +463,7 @@ class CatalogService
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
'El atributo no pertenece al ítem de catálogo.',
__('api.catalog.attribute_not_on_item'),
],
]);
}
@@ -489,7 +489,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.',
__('api.catalog.invalid_effective_date_range'),
],
]);
}
@@ -508,7 +508,7 @@ class CatalogService
if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem con attribute_codes debe tener variantes.',
__('api.catalog.variants_required'),
],
]);
}
@@ -516,7 +516,7 @@ class CatalogService
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem sin attribute_codes no puede tener variantes.',
__('api.catalog.variants_forbidden'),
],
]);
}
@@ -524,7 +524,7 @@ class CatalogService
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
'Un ítem con variantes no puede tener inventario directo.',
__('api.catalog.direct_inventory_forbidden'),
],
]);
}
@@ -536,7 +536,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
'inventory' => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}

View File

@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*
* @param TelepagosWebhookRequest $request
* @param string $tenantCodigo
* @param TelepagosWebhookService $service
* @return JsonResponse
*/
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (!$integration) {
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try {
$this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
);
return response()->json([
'message' => 'integration configured correctly',
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'Error validando la configuración: ' . $e->getMessage()
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400);
}
}

View File

@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (!$this->integrationModel) {
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => 'Integration not found.'
'integration_code' => __('api.integration.not_configured'),
]);
}
}
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
// Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.' . $field] = $rule;
$rules['integration_data.'.$field] = $rule;
}
}

View File

@@ -24,7 +24,8 @@ class MailTestService
);
return [
'message' => 'Correo de prueba enviado correctamente.',
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),

View File

@@ -48,7 +48,7 @@ class Menu extends Model
&& empty($menu->static_content_schema)
) {
throw ValidationException::withMessages([
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.',
'static_content_schema' => __('api.menu.schema_required'),
]);
}
});

View File

@@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest
if (! $this->menuModel) {
throw ValidationException::withMessages([
'menu_code' => 'El menú indicado no existe.',
'menu_code' => __('api.menu.not_found'),
]);
}
}

View File

@@ -135,7 +135,7 @@ class PurchaseController extends Controller
if ($updated === 0) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer available for payment.',
'purchase' => __('api.purchase.not_available_for_payment'),
]);
}
@@ -159,8 +159,14 @@ class PurchaseController extends Controller
],
]);
} catch (\Exception $e) {
Log::error('Unable to retrieve TelePagos account information.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error getting account info: '.$e->getMessage(),
'code' => 'integration.account_info_failed',
'message' => __('api.integration.account_info_failed'),
], 500);
}
}
@@ -182,8 +188,14 @@ class PurchaseController extends Controller
'qr_code' => $qrResponse['qr_code'] ?? '',
]);
} catch (\Exception $e) {
Log::error('Unable to generate TelePagos QR code.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error generating QR: '.$e->getMessage(),
'code' => 'integration.qr_generation_failed',
'message' => __('api.integration.qr_generation_failed'),
], 500);
}
@@ -197,7 +209,8 @@ class PurchaseController extends Controller
}
return response()->json([
'message' => 'Invalid payment method.',
'code' => 'integration.invalid_payment_method',
'message' => __('api.integration.invalid_payment_method'),
], 400);
}
@@ -222,7 +235,7 @@ class PurchaseController extends Controller
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException('Purchase not found for tenant.');
throw new NotFoundHttpException(__('api.errors.not_found'));
}
return $purchase;

View File

@@ -41,7 +41,7 @@ class CheckoutService
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => 'A cart or direct item is required.',
'cart_id' => __('api.purchase.source_required'),
]);
}
@@ -64,7 +64,7 @@ class CheckoutService
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => 'The purchase payment method must be selected before finalizing.',
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
@@ -102,7 +102,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -128,7 +128,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -144,7 +144,7 @@ class CheckoutService
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => 'The purchase item is no longer editable.',
'item' => __('api.purchase.item_not_editable'),
]);
}
@@ -162,7 +162,7 @@ class CheckoutService
}
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'quantity' => 'No hay suficiente stock disponible.',
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
@@ -197,7 +197,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -233,7 +233,7 @@ class CheckoutService
Purchase::STATUS_EXPIRED,
], true)) {
throw ValidationException::withMessages([
'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.',
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
@@ -249,7 +249,7 @@ class CheckoutService
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.',
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
@@ -306,7 +306,7 @@ class CheckoutService
}
throw ValidationException::withMessages([
'purchase' => 'A paid purchase cannot be cancelled.',
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
@@ -339,7 +339,7 @@ class CheckoutService
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.',
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
@@ -375,7 +375,7 @@ class CheckoutService
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.",
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
@@ -383,7 +383,7 @@ class CheckoutService
$this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'direct_item.cantidad' => 'No hay suficiente stock disponible.',
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
@@ -416,7 +416,7 @@ class CheckoutService
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart does not contain items.',
'cart_id' => __('api.purchase.empty_cart'),
]);
}
@@ -596,13 +596,13 @@ class CheckoutService
foreach ($cartItems as $item) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items could not be loaded.',
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items do not belong to the tenant.',
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
@@ -621,7 +621,7 @@ class CheckoutService
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart is no longer active.',
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
@@ -682,13 +682,13 @@ class CheckoutService
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => 'Un bundle no admite una variante.',
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.',
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
@@ -698,7 +698,7 @@ class CheckoutService
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.',
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}

View File

@@ -225,7 +225,7 @@ class TenantService
if ($attachment === null) {
throw ValidationException::withMessages([
"main_carousel_images.{$order}" => [
'La imagen indicada no existe o no es un attachment de tipo imagen.',
__('api.tenant.invalid_carousel_image'),
],
]);
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticket\Controllers;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
use App\Domains\Ticket\Resources\TicketResource;
@@ -37,11 +38,9 @@ class TicketController extends Controller
->orderByDesc('id')
->get();
abort_if(
$tickets->count() !== count($ticketIds),
404,
'Uno o más tickets no están disponibles.'
);
if ($tickets->count() !== count($ticketIds)) {
throw new TicketNotAvailableException(__('api.ticket.not_available'));
}
return $this->ticketPdfService->download($tenant, $tickets);
}

View File

@@ -11,22 +11,22 @@ class TicketGenerationException extends RuntimeException
{
public static function invalidQuantity(): self
{
return new self('La cantidad de tickets a generar debe ser mayor a cero.');
return new self(__('api.ticket.positive_quantity'));
}
public static function emptyBundle(CatalogItem $bundle): self
{
return new self("El bundle {$bundle->id} no tiene componentes.");
return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
}
public static function ticketsDisabled(CatalogItem $catalogItem): self
{
return new self("El producto {$catalogItem->id} no tiene tickets habilitados.");
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
}
public static function maximumUseDateReached(CatalogItem $catalogItem): self
{
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso.");
return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
}
public static function variantNotFound(
@@ -34,17 +34,20 @@ class TicketGenerationException extends RuntimeException
int $variantId,
): self {
return new self(
"La variante {$variantId} no pertenece al producto {$catalogItem->id}.",
__('api.ticket.invalid_variant', [
'variant' => $variantId,
'product' => $catalogItem->id,
]),
);
}
public static function purchaseWithoutUser(Purchase $purchase): self
{
return new self("La compra {$purchase->id} no tiene un usuario asociado.");
return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
}
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
{
return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}.");
return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domains\Ticket\Exceptions;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TicketNotAvailableException extends NotFoundHttpException {}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetApiLocale
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->is('api/*')) {
return $next($request);
}
$supportedLocales = config('app.supported_locales', ['es', 'en']);
$acceptLanguage = $request->header('Accept-Language');
$locale = is_string($acceptLanguage) && $acceptLanguage !== ''
? $request->getPreferredLanguage($supportedLocales)
: null;
$locale ??= config('app.locale', 'es');
App::setLocale($locale);
$response = $next($request);
$response->headers->set('Content-Language', $locale);
$response->setVary('Accept-Language', false);
return $response;
}
}

View File

@@ -1,9 +1,16 @@
<?php
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Http\Middleware\SetApiLocale;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -16,9 +23,70 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: [
'guest_token',
]);
$middleware->prepend(SetApiLocale::class);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
$exceptions->render(function (AuthenticationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'auth.unauthenticated',
'message' => __('api.auth.unauthenticated'),
], 401);
});
$exceptions->render(function (AuthorizationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.forbidden',
'message' => __('api.errors.forbidden'),
], 403);
});
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.not_found',
'message' => __('api.errors.not_found'),
], 404);
});
$exceptions->render(function (TicketNotAvailableException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'ticket.not_available',
'message' => __('api.ticket.not_available'),
], 404);
});
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.not_found',
'message' => __('api.errors.not_found'),
], 404);
});
$exceptions->render(function (MethodNotAllowedHttpException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.method_not_allowed',
'message' => __('api.errors.method_not_allowed'),
], 405, $exception->getHeaders());
});
})->create();

View File

@@ -78,9 +78,11 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'es'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'),
'supported_locales' => ['es', 'en'],
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),

104
lang/en/api.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
return [
'auth' => [
'unauthenticated' => 'Unauthenticated.',
'invalid_credentials' => 'Email or password is incorrect.',
'login_success' => 'Signed in successfully.',
'logout_success' => 'Signed out successfully.',
'register_success' => 'User registered successfully.',
'password_reset_requested' => 'If the email is registered, you will receive a code to reset your password.',
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.',
'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.',
'invalid_return_url' => 'The return URL is invalid.',
'oauth_code_expired' => 'The authentication code expired or has already been used.',
'tenant_mismatch' => 'The tenant does not match the authentication request.',
'google_email_required' => 'Google did not return an email for the account.',
'google_email_unverified' => 'The Google account must have a verified email.',
],
'cart' => [
'item_added' => 'Product added to cart.',
'quantity_updated' => 'Product quantity updated.',
'item_removed' => 'Product removed from cart.',
'positive_quantity' => 'The quantity must be greater than zero.',
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
'max_quantity' => 'You can add a maximum of :max.',
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'variant_required' => 'You must select a variant for this item.',
],
'purchase' => [
'source_required' => 'A cart or direct item is required.',
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
'not_editable' => 'The purchase is no longer editable.',
'item_not_editable' => 'The purchase item is no longer editable.',
'insufficient_stock' => 'There is not enough stock available.',
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.',
'empty_cart' => 'The selected cart does not contain items.',
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
'inactive_cart' => 'The selected cart is no longer active.',
'not_available_for_payment' => 'The purchase is no longer available for payment.',
],
'ticket' => [
'not_available' => 'One or more tickets are not available.',
'positive_quantity' => 'The number of tickets to generate must be greater than zero.',
'empty_bundle' => 'Bundle :bundle has no components.',
'disabled' => 'Product :product does not have tickets enabled.',
'expired' => 'Product :product has reached its maximum usage date.',
'invalid_variant' => 'Variant :variant does not belong to product :product.',
'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
'product_not_found' => 'The product for purchase item :purchase_item was not found.',
],
'integration' => [
'not_configured' => 'The integration is not configured for this tenant.',
'configured' => 'Integration configured successfully.',
'validation_failed' => 'Configuration validation failed: :error',
'invalid_payment_method' => 'Invalid payment method.',
'account_info_failed' => 'Could not retrieve account information.',
'qr_generation_failed' => 'Could not generate the QR code.',
'webhook_failed' => 'The webhook could not be processed.',
],
'mail' => [
'test_sent' => 'Test email sent successfully.',
],
'catalog' => [
'standard_with_components' => 'A standard item cannot have components.',
'duplicate_component' => 'The component is duplicated.',
'component_wrong_tenant' => 'The item does not belong to the bundle tenant.',
'invalid_component' => 'The component must be a standard item other than the bundle.',
'component_variant_required' => 'You must select a variant for this component.',
'component_variant_forbidden' => 'A component with direct inventory cannot have a variant.',
'component_variant_invalid' => 'The variant does not belong to the selected component.',
'bundle_component_required' => 'A bundle must have at least one component.',
'bundle_field_forbidden' => ':field is not allowed for a bundle.',
'attachment_not_found' => 'The specified attachment does not exist.',
'attribute_not_found' => 'Attribute :attribute does not exist for the item tenant.',
'managed_inventory_fields' => 'inventory_id, reserved_stock, and sold_units are managed internally.',
'attribute_not_on_item' => 'The attribute does not belong to the catalog item.',
'invalid_effective_date_range' => 'The effective maximum date must be after or equal to the effective minimum date.',
'variants_required' => 'An item with attribute_codes must have variants.',
'variants_forbidden' => 'An item without attribute_codes cannot have variants.',
'direct_inventory_forbidden' => 'An item with variants cannot have direct inventory.',
],
'menu' => [
'schema_required' => 'The schema is required for static menus.',
'not_found' => 'The specified menu does not exist.',
],
'tenant' => [
'invalid_carousel_image' => 'The specified image does not exist or is not an image attachment.',
],
'errors' => [
'forbidden' => 'You do not have permission to perform this action.',
'not_found' => 'The requested resource was not found.',
'method_not_allowed' => 'The HTTP method is not allowed for this route.',
'request_failed' => 'The request could not be processed.',
],
];

57
lang/en/validation.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
return [
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'array' => 'The :attribute must be an array.',
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute must be a valid date.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'integer' => 'The :attribute must be an integer.',
'max' => [
'array' => 'The :attribute must not have more than :max items.',
'file' => 'The :attribute must not be greater than :max kilobytes.',
'numeric' => 'The :attribute must not be greater than :max.',
'string' => 'The :attribute must not be greater than :max characters.',
],
'min' => [
'array' => 'The :attribute must have at least :min items.',
'file' => 'The :attribute must be at least :min kilobytes.',
'numeric' => 'The :attribute must be at least :min.',
'string' => 'The :attribute must be at least :min characters.',
],
'numeric' => 'The :attribute must be a number.',
'password' => [
'letters' => 'The :attribute must contain at least one letter.',
'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute must contain at least one number.',
'symbols' => 'The :attribute must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different one.',
],
'prohibited' => 'The :attribute field is prohibited.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'string' => 'The :attribute must be a string.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
'attributes' => [
'cantidad' => 'quantity',
'catalog_item_id' => 'product',
'codigo' => 'code',
'dni' => 'ID number',
'email' => 'email',
'nombre_apellido' => 'full name',
'oauth_code' => 'authentication code',
'password' => 'password',
'password_confirmation' => 'password confirmation',
'telefono' => 'phone number',
'tenant_codigo' => 'tenant',
'variant_id' => 'variant',
],
];

104
lang/es/api.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
return [
'auth' => [
'unauthenticated' => 'No autenticado.',
'invalid_credentials' => 'Email o contraseña incorrectos.',
'login_success' => 'Sesión iniciada correctamente.',
'logout_success' => 'Sesión cerrada correctamente.',
'register_success' => 'Usuario registrado correctamente.',
'password_reset_requested' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.',
'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
'invalid_return_url' => 'La URL de retorno no es válida.',
'oauth_code_expired' => 'El código de autenticación expiró o ya fue utilizado.',
'tenant_mismatch' => 'El tenant no coincide con la solicitud de autenticación.',
'google_email_required' => 'Google no devolvió un email para la cuenta.',
'google_email_unverified' => 'La cuenta de Google debe tener el email verificado.',
],
'cart' => [
'item_added' => 'Producto agregado al carrito.',
'quantity_updated' => 'Cantidad de producto actualizada.',
'item_removed' => 'Producto eliminado del carrito.',
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
'max_quantity' => 'El máximo que se puede agregar es :max.',
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
],
'purchase' => [
'source_required' => 'Se requiere un carrito o un producto directo.',
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
'not_editable' => 'La compra ya no se puede modificar.',
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
'insufficient_stock' => 'No hay suficiente stock disponible.',
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
'empty_cart' => 'El carrito seleccionado no contiene productos.',
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
],
'ticket' => [
'not_available' => 'Uno o más tickets no están disponibles.',
'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.',
'empty_bundle' => 'El bundle :bundle no tiene componentes.',
'disabled' => 'El producto :product no tiene tickets habilitados.',
'expired' => 'El producto :product alcanzó su fecha máxima de uso.',
'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
],
'integration' => [
'not_configured' => 'La integración no está configurada para este tenant.',
'configured' => 'Integración configurada correctamente.',
'validation_failed' => 'Error validando la configuración: :error',
'invalid_payment_method' => 'Método de pago inválido.',
'account_info_failed' => 'No se pudo obtener la información de la cuenta.',
'qr_generation_failed' => 'No se pudo generar el código QR.',
'webhook_failed' => 'No se pudo procesar el webhook.',
],
'mail' => [
'test_sent' => 'Correo de prueba enviado correctamente.',
],
'catalog' => [
'standard_with_components' => 'Un ítem standard no puede tener componentes.',
'duplicate_component' => 'El componente está duplicado.',
'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.',
'invalid_component' => 'El componente debe ser un ítem standard distinto del bundle.',
'component_variant_required' => 'Debe seleccionar una variante para este componente.',
'component_variant_forbidden' => 'El componente con inventario directo no admite una variante.',
'component_variant_invalid' => 'La variante no pertenece al componente indicado.',
'bundle_component_required' => 'Un bundle debe tener al menos un componente.',
'bundle_field_forbidden' => ':field no se admite para un bundle.',
'attachment_not_found' => 'El attachment indicado no existe.',
'attribute_not_found' => 'El atributo :attribute no existe para el tenant del ítem.',
'managed_inventory_fields' => 'inventory_id, reserved_stock y sold_units son administrados internamente.',
'attribute_not_on_item' => 'El atributo no pertenece al ítem de catálogo.',
'invalid_effective_date_range' => 'La fecha máxima efectiva debe ser posterior o igual a la fecha mínima efectiva.',
'variants_required' => 'Un ítem con attribute_codes debe tener variantes.',
'variants_forbidden' => 'Un ítem sin attribute_codes no puede tener variantes.',
'direct_inventory_forbidden' => 'Un ítem con variantes no puede tener inventario directo.',
],
'menu' => [
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
'not_found' => 'El menú indicado no existe.',
],
'tenant' => [
'invalid_carousel_image' => 'La imagen indicada no existe o no es un attachment de tipo imagen.',
],
'errors' => [
'forbidden' => 'No tienes permiso para realizar esta acción.',
'not_found' => 'El recurso solicitado no fue encontrado.',
'method_not_allowed' => 'El método HTTP no está permitido para esta ruta.',
'request_failed' => 'No se pudo procesar la solicitud.',
],
];

57
lang/es/validation.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
return [
'after_or_equal' => ':Attribute debe ser una fecha posterior o igual a :date.',
'array' => ':Attribute debe ser un arreglo.',
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
'confirmed' => 'La confirmación de :attribute no coincide.',
'date' => ':Attribute no es una fecha válida.',
'distinct' => 'El campo :attribute tiene un valor duplicado.',
'email' => ':Attribute debe ser una dirección de email válida.',
'exists' => 'El valor seleccionado para :attribute no es válido.',
'file' => ':Attribute debe ser un archivo.',
'integer' => ':Attribute debe ser un número entero.',
'max' => [
'array' => ':Attribute no debe tener más de :max elementos.',
'file' => ':Attribute no debe superar los :max kilobytes.',
'numeric' => ':Attribute no debe ser mayor que :max.',
'string' => ':Attribute no debe tener más de :max caracteres.',
],
'min' => [
'array' => ':Attribute debe tener al menos :min elementos.',
'file' => ':Attribute debe pesar al menos :min kilobytes.',
'numeric' => ':Attribute debe ser al menos :min.',
'string' => ':Attribute debe tener al menos :min caracteres.',
],
'numeric' => ':Attribute debe ser un número.',
'password' => [
'letters' => ':Attribute debe contener al menos una letra.',
'mixed' => ':Attribute debe contener al menos una letra mayúscula y una minúscula.',
'numbers' => ':Attribute debe contener al menos un número.',
'symbols' => ':Attribute debe contener al menos un símbolo.',
'uncompromised' => ':Attribute aparece en una filtración de datos. Elige una contraseña diferente.',
],
'prohibited' => 'El campo :attribute está prohibido.',
'regex' => 'El formato de :attribute no es válido.',
'required' => 'El campo :attribute es obligatorio.',
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'string' => ':Attribute debe ser texto.',
'unique' => 'El valor de :attribute ya está en uso.',
'url' => ':Attribute debe ser una URL válida.',
'uuid' => ':Attribute debe ser un UUID válido.',
'attributes' => [
'cantidad' => 'cantidad',
'catalog_item_id' => 'producto',
'codigo' => 'código',
'dni' => 'DNI',
'email' => 'email',
'nombre_apellido' => 'nombre y apellido',
'oauth_code' => 'código de autenticación',
'password' => 'contraseña',
'password_confirmation' => 'confirmación de contraseña',
'telefono' => 'teléfono',
'tenant_codigo' => 'tenant',
'variant_id' => 'variante',
],
];

View File

@@ -35,7 +35,9 @@ class LoginControllerTest extends TestCase
$response
->assertOk()
->assertJsonPath('message', 'Sesion iniciada correctamente.')
->assertHeader('Content-Language', 'es')
->assertJsonPath('code', 'auth.login_success')
->assertJsonPath('message', 'Sesión iniciada correctamente.')
->assertJsonPath('token_type', 'Bearer')
->assertJsonPath('user.id', $user->id)
->assertJsonPath('user.nombre_apellido', 'Grace Hopper')
@@ -85,7 +87,8 @@ class LoginControllerTest extends TestCase
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/logout')
->assertOk()
->assertJsonPath('message', 'Sesion cerrada correctamente.');
->assertJsonPath('code', 'auth.logout_success')
->assertJsonPath('message', 'Sesión cerrada correctamente.');
$this->assertDatabaseCount('personal_access_tokens', 0);
}
@@ -118,6 +121,34 @@ class LoginControllerTest extends TestCase
]);
}
public function test_it_localizes_success_and_validation_responses_from_accept_language(): void
{
$tenant = $this->createTenant('acme');
User::query()->create([
'nombre_apellido' => 'Grace Hopper',
'email' => 'grace@example.com',
'password' => Hash::make('secret123'),
]);
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
->postJson('/api/login', [
'email' => 'grace@example.com',
'password' => 'secret123',
'tenant_codigo' => $tenant->codigo,
])
->assertOk()
->assertHeader('Content-Language', 'en')
->assertJsonPath('code', 'auth.login_success')
->assertJsonPath('message', 'Signed in successfully.');
$this->withHeader('Accept-Language', 'en')
->postJson('/api/login', [])
->assertUnprocessable()
->assertHeader('Content-Language', 'en')
->assertJsonPath('errors.email.0', 'The email field is required.')
->assertJsonPath('errors.password.0', 'The password field is required.');
}
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void
{
$tenant = $this->createTenant('acme');

View File

@@ -31,7 +31,7 @@ class TenantIntegrationControllerTest extends TestCase
Mockery::on(fn (Integration $argument) => $argument->is($integration)),
['api_key' => 'secret']
)
->andReturn(new TenantIntegration());
->andReturn(new TenantIntegration);
});
$this->postJson('/api/test-tenant/integrations/test_integration', [
@@ -40,7 +40,8 @@ class TenantIntegrationControllerTest extends TestCase
],
])->assertOk()
->assertExactJson([
'message' => 'integration configured correctly',
'code' => 'integration.configured',
'message' => 'Integración configurada correctamente.',
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Feature\Localization;
use Tests\TestCase;
class ApiLocalizationTest extends TestCase
{
public function test_it_negotiates_spanish_from_accept_language(): void
{
$this->withHeader('Accept-Language', 'es-AR,es;q=0.9')
->postJson('/api/login')
->assertUnprocessable()
->assertHeader('Content-Language', 'es')
->assertJsonPath('errors.email.0', 'El campo email es obligatorio.')
->assertJsonPath('errors.password.0', 'El campo contraseña es obligatorio.');
}
public function test_it_negotiates_english_from_accept_language(): void
{
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
->postJson('/api/login')
->assertUnprocessable()
->assertHeader('Content-Language', 'en')
->assertJsonPath('errors.email.0', 'The email field is required.')
->assertJsonPath('errors.password.0', 'The password field is required.');
}
public function test_it_localizes_framework_error_responses(): void
{
$this->withHeader('Accept-Language', 'en')
->getJson('/api/route-that-does-not-exist')
->assertNotFound()
->assertHeader('Content-Language', 'en')
->assertJson([
'code' => 'errors.not_found',
'message' => 'The requested resource was not found.',
]);
}
}