Compare commits
12 Commits
e2ad78b1fb
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 |
@@ -5,9 +5,6 @@ APP_DEBUG=false
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
|
||||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
@@ -31,6 +28,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
|||||||
AUTH_LOGIN_LOCK_MINUTES=15
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||||
|
|
||||||
LOG_CHANNEL=daily
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
)) {
|
);
|
||||||
|
|
||||||
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_expired'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.reset_code_invalid'),
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
|
public const CODE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const CODE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const CODE_EXPIRED = 'expired';
|
||||||
|
|
||||||
public function createForEmail(
|
public function createForEmail(
|
||||||
string $email,
|
string $email,
|
||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): bool
|
public function validateCode(string $email, string $code): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
|
|||||||
'email_fingerprint' => $emailFingerprint,
|
'email_fingerprint' => $emailFingerprint,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return self::CODE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset code validation failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::CODE_EXPIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attempt->update([
|
$attempt->update([
|
||||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return self::CODE_VALID;
|
||||||
});
|
});
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Failed to validate password reset code.', [
|
Log::error('Failed to validate password reset code.', [
|
||||||
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user->password = $password;
|
$user->password = $password;
|
||||||
$user->failed_login_attempts = 0;
|
$user->failed_login_attempts = 0;
|
||||||
$user->last_failed_login_at = null;
|
$user->last_failed_login_at = null;
|
||||||
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
|
|||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $attempt->getKey();
|
return $attempt->getKey();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
|||||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('dominio', $domain)
|
->where('dominio', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('scanner_domain', $domain)
|
->where('scanner_domain', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -343,9 +343,9 @@ class StockReservationService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
|
public function clearExpirationForReview(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($purchase, $expiresAt): void {
|
DB::transaction(function () use ($purchase): void {
|
||||||
/** @var Purchase $purchase */
|
/** @var Purchase $purchase */
|
||||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
if ($purchase->stock_reservation_id === null) {
|
if ($purchase->stock_reservation_id === null) {
|
||||||
@@ -363,7 +363,7 @@ class StockReservationService
|
|||||||
throw new StockReservationExpiredException;
|
throw new StockReservationExpiredException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reservation->update(['expires_at' => $expiresAt]);
|
$reservation->update(['expires_at' => null]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ class PurchaseController extends Controller
|
|||||||
PaymentIntentRequest $request,
|
PaymentIntentRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
CheckoutService $checkoutService,
|
|
||||||
PurchaseStateGuard $purchaseState,
|
PurchaseStateGuard $purchaseState,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
@@ -103,7 +102,6 @@ class PurchaseController extends Controller
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$updated = DB::transaction(function () use (
|
$updated = DB::transaction(function () use (
|
||||||
$checkoutService,
|
|
||||||
$compra,
|
$compra,
|
||||||
$method,
|
$method,
|
||||||
$purchaseState,
|
$purchaseState,
|
||||||
@@ -142,12 +140,6 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
|
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
|
||||||
}
|
}
|
||||||
$purchase->update($purchaseUpdate);
|
$purchase->update($purchaseUpdate);
|
||||||
$checkoutService->refreshReservationExpiration(
|
|
||||||
$purchase,
|
|
||||||
now()->addMinutes(
|
|
||||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class PurchaseResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$serverTime = now();
|
||||||
|
$expiresAt = $this->stockReservation?->expires_at;
|
||||||
$items = $this->resource->relationLoaded('items')
|
$items = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
@@ -48,7 +50,11 @@ class PurchaseResource extends JsonResource
|
|||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
'payment_method' => $this->payment_method,
|
||||||
'expires_at' => $this->stockReservation?->expires_at,
|
'expires_at' => $expiresAt,
|
||||||
|
'expires_in_seconds' => $expiresAt === null
|
||||||
|
? null
|
||||||
|
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
|
||||||
|
'server_time' => $serverTime,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class CompleteCheckoutService
|
|||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
]);
|
]);
|
||||||
$this->reservations->refreshForPurchase($purchase, null);
|
$this->reservations->clearExpirationForReview($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +22,6 @@ class CheckoutService
|
|||||||
private readonly EditCheckoutService $editor,
|
private readonly EditCheckoutService $editor,
|
||||||
private readonly CompleteCheckoutService $completer,
|
private readonly CompleteCheckoutService $completer,
|
||||||
private readonly ReleaseCheckoutService $releaser,
|
private readonly ReleaseCheckoutService $releaser,
|
||||||
private readonly StockReservationService $reservations,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
@@ -78,9 +75,4 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
return $this->releaser->expire($purchase);
|
return $this->releaser->expire($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void
|
|
||||||
{
|
|
||||||
$this->reservations->refreshForPurchase($purchase, $expiresAt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class WebsiteTypeService
|
|||||||
&& $previousLogo->id !== $websiteType->site_logo
|
&& $previousLogo->id !== $websiteType->site_logo
|
||||||
&& $previousLogo->id !== $websiteType->footer_logo
|
&& $previousLogo->id !== $websiteType->footer_logo
|
||||||
&& $previousLogo->id !== $websiteType->favicon_id
|
&& $previousLogo->id !== $websiteType->favicon_id
|
||||||
|
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||||
) {
|
) {
|
||||||
$this->attachmentService->delete($previousLogo);
|
$this->attachmentService->delete($previousLogo);
|
||||||
}
|
}
|
||||||
@@ -90,4 +91,16 @@ class WebsiteTypeService
|
|||||||
return $websiteType;
|
return $websiteType;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isReferencedByWebsiteType(Attachment $attachment): bool
|
||||||
|
{
|
||||||
|
return WebsiteType::query()
|
||||||
|
->where(function ($query) use ($attachment): void {
|
||||||
|
$query
|
||||||
|
->where('site_logo', $attachment->id)
|
||||||
|
->orWhere('footer_logo', $attachment->id)
|
||||||
|
->orWhere('favicon_id', $attachment->id);
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ return [
|
|||||||
'users' => [
|
'users' => [
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
'expire' => 60,
|
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,10 +2,4 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
||||||
|
|
||||||
'payment_expiration_minutes' => [
|
|
||||||
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
|
|
||||||
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
|
||||||
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('expires_at')->nullable()->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempts created before this migration did not have an expiration instant.
|
||||||
|
DB::table('reset_password_attempts')
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const FILENAME = 'onticket_favicon.svg';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const WEBSITE_TYPE_CODES = ['shopit', 'onticket'];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$websiteTypes = DB::table('website_type')
|
||||||
|
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||||
|
->get(['codigo', 'favicon_id']);
|
||||||
|
|
||||||
|
if ($websiteTypes->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$faviconIds = $websiteTypes
|
||||||
|
->pluck('favicon_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$faviconIds->count() === 1
|
||||||
|
&& DB::table('attachments')
|
||||||
|
->where('id', $faviconIds->first())
|
||||||
|
->where('filename', self::FILENAME)
|
||||||
|
->exists()
|
||||||
|
&& $websiteTypes->every(
|
||||||
|
fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = public_path('images/website_types/'.self::FILENAME);
|
||||||
|
|
||||||
|
if (! is_file($sourcePath)) {
|
||||||
|
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($sourcePath);
|
||||||
|
|
||||||
|
if ($contents === false) {
|
||||||
|
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = (string) Str::uuid();
|
||||||
|
$storedPath = "website-types/{$key}.svg";
|
||||||
|
|
||||||
|
if (! Storage::disk('s3')->put($storedPath, $contents)) {
|
||||||
|
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($contents, $key, $storedPath): void {
|
||||||
|
$attachmentId = DB::table('attachments')->insertGetId([
|
||||||
|
'key' => $key,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'filename' => self::FILENAME,
|
||||||
|
'type' => 'image',
|
||||||
|
'mime_type' => 'image/svg+xml',
|
||||||
|
'extension' => 'svg',
|
||||||
|
'size' => strlen($contents),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('website_type')
|
||||||
|
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||||
|
->update([
|
||||||
|
'favicon_id' => $attachmentId,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
Storage::disk('s3')->delete($storedPath);
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// The shared attachment may be in use outside these website types.
|
||||||
|
// Keep this data migration irreversible to avoid deleting an active asset.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $this->onTicketFavicon(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function onTicketFavicon(): UploadedFile
|
||||||
|
{
|
||||||
|
$path = public_path('images/website_types/onticket_favicon.svg');
|
||||||
|
|
||||||
|
if (! file_exists($path)) {
|
||||||
|
throw new RuntimeException("OnTicket favicon not found at path: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UploadedFile(
|
||||||
|
$path,
|
||||||
|
'onticket_favicon.svg',
|
||||||
|
'image/svg+xml',
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
||||||
'password_updated' => 'Password updated successfully.',
|
'password_updated' => 'Password updated successfully.',
|
||||||
'reset_code_invalid' => 'The code you entered is invalid.',
|
'reset_code_invalid' => 'The code you entered is invalid.',
|
||||||
|
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
|
||||||
'reset_code_valid' => 'Code validated successfully.',
|
'reset_code_valid' => 'Code validated successfully.',
|
||||||
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
||||||
'request_expired' => 'The authentication request expired. Please try again.',
|
'request_expired' => 'The authentication request expired. Please try again.',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||||
'password_updated' => 'Contraseña modificada correctamente.',
|
'password_updated' => 'Contraseña modificada correctamente.',
|
||||||
'reset_code_invalid' => 'El código ingresado es inválido.',
|
'reset_code_invalid' => 'El código ingresado es inválido.',
|
||||||
|
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
|
||||||
'reset_code_valid' => 'Código validado correctamente.',
|
'reset_code_valid' => 'Código validado correctamente.',
|
||||||
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
'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.',
|
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
||||||
|
|||||||
3
public/images/website_types/onticket_favicon.svg
Normal file
3
public/images/website_types/onticket_favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="63" height="36" viewBox="0 0 63 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M44.64 0H17.94C8.05 0 0 8.05 0 17.94C0 27.83 8.05 35.88 17.94 35.88H44.64C54.53 35.88 62.58 27.83 62.58 17.94C62.58 8.05 54.53 0 44.64 0ZM44.62 31.78C36.98 31.78 30.79 25.59 30.79 17.95C30.79 10.31 36.98 4.12 44.62 4.12C52.26 4.12 58.45 10.31 58.45 17.95C58.45 25.59 52.26 31.78 44.62 31.78Z" fill="#FF7006"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 422 B |
@@ -69,6 +69,10 @@ class CreateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
|
$this->assertTrue($attempt->expires_at->between(
|
||||||
|
now()->addMinutes(59),
|
||||||
|
now()->addMinutes(60),
|
||||||
|
));
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
PasswordResetRequested::class,
|
PasswordResetRequested::class,
|
||||||
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
'id',
|
'id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'codigo',
|
'codigo',
|
||||||
|
'reason',
|
||||||
'status',
|
'status',
|
||||||
|
'expires_at',
|
||||||
], Schema::getColumnListing('reset_password_attempts'));
|
], Schema::getColumnListing('reset_password_attempts'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => '123456',
|
'codigo' => '123456',
|
||||||
|
'expires_at' => now()->addHour(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
||||||
$this->assertFalse($attempt->usesTimestamps());
|
$this->assertFalse($attempt->usesTimestamps());
|
||||||
|
$this->assertTrue($attempt->expires_at->isFuture());
|
||||||
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
|
|||||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_an_expired_validated_attempt_cannot_reset_the_password(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'OldSecret!123',
|
||||||
|
]);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $attempt->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_validates_password_confirmation_and_strength(): void
|
public function test_it_validates_password_confirmation_and_strength(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/api/password/reset', [
|
$this->postJson('/api/password/reset', [
|
||||||
|
|||||||
@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo')
|
||||||
|
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
$attempt->fresh()->status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
->assertJsonValidationErrors(['transfer_payer_dni']);
|
->assertJsonValidationErrors(['transfer_payer_dni']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
|
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
|
||||||
{
|
{
|
||||||
config()->set('purchase.payment_expiration_minutes.transfer', 60);
|
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||||
$now = now()->startOfSecond();
|
$now = now()->startOfSecond();
|
||||||
$this->travelTo($now);
|
$this->travelTo($now);
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'id' => $purchase->stock_reservation_id,
|
'id' => $purchase->stock_reservation_id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'expires_at' => $now->copy()->addMinutes(60)->toDateTimeString(),
|
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->travelBack();
|
$this->travelBack();
|
||||||
|
|||||||
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SetWebsiteTypeFaviconTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_uploads_one_favicon_and_shares_the_attachment_between_website_types(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
DB::table('website_type')->insert([
|
||||||
|
[
|
||||||
|
'codigo' => 'shopit',
|
||||||
|
'nombre' => 'ShopIt',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$migration = require database_path(
|
||||||
|
'migrations/2026_08_26_000000_set_website_type_favicon.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration->up();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$faviconIds = DB::table('website_type')
|
||||||
|
->whereIn('codigo', ['shopit', 'onticket'])
|
||||||
|
->pluck('favicon_id');
|
||||||
|
|
||||||
|
$this->assertCount(2, $faviconIds);
|
||||||
|
$this->assertNotNull($faviconIds->first());
|
||||||
|
$this->assertSame(1, $faviconIds->unique()->count());
|
||||||
|
|
||||||
|
$attachment = DB::table('attachments')->where('id', $faviconIds->first())->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($attachment);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $attachment->filename);
|
||||||
|
$this->assertSame('image/svg+xml', $attachment->mime_type);
|
||||||
|
$this->assertSame('svg', $attachment->extension);
|
||||||
|
$this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count());
|
||||||
|
Storage::disk('s3')->assertExists($attachment->path);
|
||||||
|
$this->assertSame(
|
||||||
|
file_get_contents(public_path('images/website_types/onticket_favicon.svg')),
|
||||||
|
Storage::disk('s3')->get($attachment->path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -796,12 +796,18 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
||||||
{
|
{
|
||||||
|
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||||
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
||||||
|
|
||||||
|
$this->travel(10)->minutes();
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
||||||
'dni' => '987654321',
|
'dni' => '987654321',
|
||||||
@@ -818,6 +824,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'dni' => '987654321',
|
'dni' => '987654321',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $purchase->stock_reservation_id,
|
||||||
|
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
|
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
$this->seed(WebsiteTypeSeeder::class);
|
$this->seed(WebsiteTypeSeeder::class);
|
||||||
|
|
||||||
$this->assertSame(2, WebsiteType::query()->count());
|
$this->assertSame(2, WebsiteType::query()->count());
|
||||||
$this->assertSame(4, Attachment::query()->count());
|
$this->assertSame(5, Attachment::query()->count());
|
||||||
|
|
||||||
$expectedPresentation = [
|
$expectedPresentation = [
|
||||||
'primary_color' => '#FF7006',
|
'primary_color' => '#FF7006',
|
||||||
@@ -49,6 +49,8 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
||||||
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
|
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
|
||||||
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
|
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename);
|
||||||
|
Storage::disk('s3')->assertExists($shopIt->favicon->path);
|
||||||
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
|
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
|
||||||
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
|
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
@@ -79,6 +81,8 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
|
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
|
||||||
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
|
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
|
||||||
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
|
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
|
||||||
|
$this->assertSame($shopIt->favicon_id, $onTicket->favicon_id);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename);
|
||||||
$this->assertEqualsCanonicalizing(
|
$this->assertEqualsCanonicalizing(
|
||||||
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
|
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
|
||||||
$onTicket->extras->pluck('codigo')->all(),
|
$onTicket->extras->pluck('codigo')->all(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
|
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
|
||||||
{
|
{
|
||||||
$footerLogo = Attachment::factory()->create();
|
$footerLogo = Attachment::factory()->create();
|
||||||
|
$favicon = Attachment::factory()->create();
|
||||||
|
|
||||||
WebsiteType::query()->create([
|
WebsiteType::query()->create([
|
||||||
'codigo' => 'shopit',
|
'codigo' => 'shopit',
|
||||||
@@ -31,6 +32,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
'border_color' => '#eaeaea',
|
'border_color' => '#eaeaea',
|
||||||
'login_header_footer_color' => '#313131',
|
'login_header_footer_color' => '#313131',
|
||||||
'footer_logo' => $footerLogo->id,
|
'footer_logo' => $footerLogo->id,
|
||||||
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
|
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
|
||||||
@@ -41,6 +43,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.login_header_footer_color', '#313131')
|
->assertJsonPath('data.login_header_footer_color', '#313131')
|
||||||
->assertJsonPath('data.site_logo', null)
|
->assertJsonPath('data.site_logo', null)
|
||||||
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
|
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
|
||||||
|
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||||
->assertJsonMissingPath('data.forms')
|
->assertJsonMissingPath('data.forms')
|
||||||
->assertJsonMissingPath('data.codigo')
|
->assertJsonMissingPath('data.codigo')
|
||||||
->assertJsonMissingPath('data.nombre')
|
->assertJsonMissingPath('data.nombre')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
||||||
{
|
{
|
||||||
$siteLogo = Attachment::factory()->create();
|
$siteLogo = Attachment::factory()->create();
|
||||||
|
$favicon = Attachment::factory()->create();
|
||||||
|
|
||||||
WebsiteType::query()->create([
|
WebsiteType::query()->create([
|
||||||
'codigo' => 'shopit',
|
'codigo' => 'shopit',
|
||||||
@@ -32,6 +33,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
'border_color' => '#eaeaea',
|
'border_color' => '#eaeaea',
|
||||||
'login_header_footer_color' => '#313131',
|
'login_header_footer_color' => '#313131',
|
||||||
'site_logo' => $siteLogo->id,
|
'site_logo' => $siteLogo->id,
|
||||||
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
||||||
@@ -40,6 +42,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.primary_color', '#112233')
|
->assertJsonPath('data.primary_color', '#112233')
|
||||||
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
||||||
->assertJsonPath('data.footer_logo', null)
|
->assertJsonPath('data.footer_logo', null)
|
||||||
|
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||||
->assertJsonMissingPath('data.codigo')
|
->assertJsonMissingPath('data.codigo')
|
||||||
->assertJsonMissingPath('data.nombre')
|
->assertJsonMissingPath('data.nombre')
|
||||||
->assertJsonMissingPath('data.dominio')
|
->assertJsonMissingPath('data.dominio')
|
||||||
|
|||||||
@@ -69,4 +69,31 @@ class WebsiteTypeServiceTest extends TestCase
|
|||||||
'favicon_id' => $favicon->id,
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$service = app(WebsiteTypeService::class);
|
||||||
|
$shopIt = $service->create([
|
||||||
|
'codigo' => 'shopit',
|
||||||
|
'nombre' => 'ShopIt',
|
||||||
|
'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32),
|
||||||
|
]);
|
||||||
|
$sharedFavicon = $shopIt->favicon()->firstOrFail();
|
||||||
|
$onTicket = $service->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
'favicon' => $sharedFavicon->key,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->updateOrCreate(
|
||||||
|
['codigo' => 'shopit'],
|
||||||
|
['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id);
|
||||||
|
$this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]);
|
||||||
|
Storage::disk('s3')->assertExists($sharedFavicon->path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ class AdminAppBootstrapResourceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$websiteType->setRelation('siteLogo', null);
|
$websiteType->setRelation('siteLogo', null);
|
||||||
$websiteType->setRelation('footerLogo', null);
|
$websiteType->setRelation('footerLogo', null);
|
||||||
|
$websiteType->setRelation('favicon', null);
|
||||||
$data = AdminAppBootstrapResource::make([
|
$data = AdminAppBootstrapResource::make([
|
||||||
'website_type' => $websiteType,
|
'website_type' => $websiteType,
|
||||||
])->resolve(request());
|
])->resolve(request());
|
||||||
|
|
||||||
$this->assertSame('shopit', $data['website_type_code']);
|
$this->assertSame('shopit', $data['website_type_code']);
|
||||||
|
$this->assertNull($data['favicon']);
|
||||||
$this->assertArrayNotHasKey('forms', $data);
|
$this->assertArrayNotHasKey('forms', $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
tests/Unit/Purchase/PurchaseResourceTest.php
Normal file
69
tests/Unit/Purchase/PurchaseResourceTest.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Purchase;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PurchaseResourceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_exposes_the_remaining_checkout_time_using_the_server_clock(): void
|
||||||
|
{
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
$expiresAt = $now->copy()->addMinutes(12);
|
||||||
|
|
||||||
|
$resource = $this->resourceFor(Purchase::STATUS_PENDING_PAYMENT, $expiresAt);
|
||||||
|
|
||||||
|
$this->assertTrue($expiresAt->equalTo($resource['expires_at']));
|
||||||
|
$this->assertSame(720, $resource['expires_in_seconds']);
|
||||||
|
$this->assertTrue($now->equalTo($resource['server_time']));
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_clamps_an_overdue_checkout_to_zero_seconds(): void
|
||||||
|
{
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
|
$resource = $this->resourceFor(
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
$now->copy()->subSecond(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(0, $resource['expires_in_seconds']);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_exposes_null_expiration_after_the_purchase_enters_review(): void
|
||||||
|
{
|
||||||
|
$resource = $this->resourceFor(
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNull($resource['expires_at']);
|
||||||
|
$this->assertNull($resource['expires_in_seconds']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function resourceFor(string $status, ?Carbon $expiresAt): array
|
||||||
|
{
|
||||||
|
$purchase = (new Purchase)->forceFill([
|
||||||
|
'status' => $status,
|
||||||
|
'total' => '0.00',
|
||||||
|
]);
|
||||||
|
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return (new PurchaseResource($purchase))->toArray(Request::create('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user