diff --git a/app/Domains/Attachable/Models/Attachment.php b/app/Domains/Attachable/Models/Attachment.php index 22ae80a..37d80c1 100644 --- a/app/Domains/Attachable/Models/Attachment.php +++ b/app/Domains/Attachable/Models/Attachment.php @@ -19,8 +19,8 @@ use Illuminate\Support\Str; 'mime_type', 'extension', 'size', - 'crop_horizontal_start_percent', - 'crop_vertical_start_percent', + 'crop_horizontal', + 'crop_vertical', 'cropped_attachment_id', ])] class Attachment extends Model @@ -43,8 +43,8 @@ class Attachment extends Model return [ 'type' => AttachmentType::class, 'size' => 'integer', - 'crop_horizontal_start_percent' => 'float', - 'crop_vertical_start_percent' => 'float', + 'crop_horizontal' => 'array', + 'crop_vertical' => 'array', 'cropped_attachment_id' => 'integer', ]; } diff --git a/app/Domains/Attachable/Services/AttachmentService.php b/app/Domains/Attachable/Services/AttachmentService.php index 6c8b0c6..3f9c7ea 100644 --- a/app/Domains/Attachable/Services/AttachmentService.php +++ b/app/Domains/Attachable/Services/AttachmentService.php @@ -17,11 +17,11 @@ class AttachmentService public function storeCroppedImage( UploadedFile|string $image, string $path, - float $horizontalCropPercentage, - float $verticalCropPercentage, + array $cropHorizontal, + array $cropVertical, ): Attachment { - $this->validateCropPercentage($horizontalCropPercentage, 'horizontal'); - $this->validateCropPercentage($verticalCropPercentage, 'vertical'); + $cropHorizontal = $this->validateCropRange($cropHorizontal, 'horizontal'); + $cropVertical = $this->validateCropRange($cropVertical, 'vertical'); $storedPaths = []; @@ -29,8 +29,8 @@ class AttachmentService return DB::transaction(function () use ( $image, $path, - $horizontalCropPercentage, - $verticalCropPercentage, + $cropHorizontal, + $cropVertical, &$storedPaths, ): Attachment { $original = $this->store($image, $path); @@ -38,8 +38,8 @@ class AttachmentService $croppedContents = $this->cropImage( $this->imageContents($image), - $horizontalCropPercentage, - $verticalCropPercentage, + $cropHorizontal, + $cropVertical, ); $cropped = $this->store( 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), @@ -48,8 +48,8 @@ class AttachmentService $storedPaths[] = $cropped->path; $original->update([ - 'crop_horizontal_start_percent' => $horizontalCropPercentage, - 'crop_vertical_start_percent' => $verticalCropPercentage, + 'crop_horizontal' => $cropHorizontal, + 'crop_vertical' => $cropVertical, 'cropped_attachment_id' => $cropped->id, ]); @@ -64,6 +64,51 @@ class AttachmentService } } + public function updateImageCrop( + Attachment $original, + array $cropHorizontal, + array $cropVertical, + ): Attachment { + if ($original->type !== AttachmentType::Image) { + throw new AttachmentStorageException('Only image attachments can be cropped.'); + } + + $cropHorizontal = $this->validateCropRange($cropHorizontal, 'horizontal'); + $cropVertical = $this->validateCropRange($cropVertical, 'vertical'); + $contents = Storage::disk('s3')->get($original->path); + + if (! is_string($contents) || $contents === '') { + throw new AttachmentStorageException('The original image could not be read from the s3 disk.'); + } + + $croppedContents = $this->cropImage($contents, $cropHorizontal, $cropVertical); + $directory = trim(str_replace('\\', '/', dirname($original->path)), './'); + $directory = $directory !== '' ? $directory : 'attachments'; + $previousCrop = $original->croppedAttachment; + $cropped = $this->store( + 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), + $directory, + ); + + try { + $original->update([ + 'crop_horizontal' => $cropHorizontal, + 'crop_vertical' => $cropVertical, + 'cropped_attachment_id' => $cropped->id, + ]); + } catch (Throwable $throwable) { + $this->delete($cropped); + + throw $throwable; + } + + if ($previousCrop !== null && ! $previousCrop->is($cropped)) { + $this->delete($previousCrop); + } + + return $original->refresh()->load('croppedAttachment'); + } + public function store( UploadedFile|string $file, string $path, @@ -168,13 +213,34 @@ class AttachmentService return trim($path, '/'); } - protected function validateCropPercentage(float $percentage, string $axis): void + /** + * @param array{start_percentage?: mixed, end_percentage?: mixed} $range + * @return array{start_percentage: float, end_percentage: float} + */ + protected function validateCropRange(array $range, string $axis): array { - if (! is_finite($percentage) || $percentage < 0 || $percentage >= 100) { + $start = $range['start_percentage'] ?? null; + $end = $range['end_percentage'] ?? null; + + if (! is_numeric($start) || ! is_numeric($end)) { throw new AttachmentStorageException( - "The {$axis} crop percentage must be greater than or equal to 0 and less than 100." + "The {$axis} crop range must contain numeric start_percentage and end_percentage values." ); } + + $start = (float) $start; + $end = (float) $end; + + if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) { + throw new AttachmentStorageException( + "The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100." + ); + } + + return [ + 'start_percentage' => $start, + 'end_percentage' => $end, + ]; } protected function imageContents(UploadedFile|string $image): string @@ -197,8 +263,8 @@ class AttachmentService */ protected function cropImage( string $contents, - float $horizontalCropPercentage, - float $verticalCropPercentage, + array $cropHorizontal, + array $cropVertical, ): array { $source = @imagecreatefromstring($contents); @@ -208,13 +274,15 @@ class AttachmentService $width = imagesx($source); $height = imagesy($source); - $x = min($width - 1, (int) floor($width * $horizontalCropPercentage / 100)); - $y = min($height - 1, (int) floor($height * $verticalCropPercentage / 100)); + $x = min($width - 1, (int) floor($width * $cropHorizontal['start_percentage'] / 100)); + $right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100)); + $y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100)); + $bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100)); $cropped = imagecrop($source, [ 'x' => $x, 'y' => $y, - 'width' => $width - $x, - 'height' => $height - $y, + 'width' => $right - $x, + 'height' => $bottom - $y, ]); if ($cropped === false) { diff --git a/app/Domains/Attachable/documentacion/README.md b/app/Domains/Attachable/documentacion/README.md index db110c3..6f28780 100644 --- a/app/Domains/Attachable/documentacion/README.md +++ b/app/Domains/Attachable/documentacion/README.md @@ -25,8 +25,8 @@ No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog ## Consideraciones - El directorio no puede quedar vacío después de normalizarlo. -- Los porcentajes de inicio del crop deben estar en el rango `[0, 100)`; el recorte se extiende desde ese punto hasta los bordes derecho e inferior. -- El attachment original guarda los porcentajes y la relación `croppedAttachment` con la versión procesada. +- Cada eje del crop guarda `start_percentage` y `end_percentage`, cumpliendo `0 <= start < end <= 100`. +- El attachment original guarda los rangos horizontal y vertical y la relación `croppedAttachment` con la versión procesada. - Al eliminar el original mediante el servicio también se elimina su versión recortada. - La eliminación se considera fallida si S3 no confirma el borrado. - Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos. diff --git a/app/Domains/Shared/Rules/CroppedImageOrBase64Rule.php b/app/Domains/Shared/Rules/CroppedImageOrBase64Rule.php new file mode 100644 index 0000000..a7b932b --- /dev/null +++ b/app/Domains/Shared/Rules/CroppedImageOrBase64Rule.php @@ -0,0 +1,68 @@ +validate($attribute, $value, $fail); + + return; + } + + if (! array_key_exists('image', $value)) { + $fail('The :attribute.image field is required.'); + + return; + } + + $valid = true; + (new ImageOrBase64Rule)->validate( + "{$attribute}.image", + $value['image'], + function (string $message) use ($fail, &$valid): void { + $valid = false; + $fail($message); + } + ); + + if (! $valid) { + return; + } + + $this->validateRange($attribute, 'crop_horizontal', $value, $fail); + $this->validateRange($attribute, 'crop_vertical', $value, $fail); + } + + private function validateRange(string $attribute, string $axis, array $value, Closure $fail): void + { + $range = $value[$axis] ?? null; + + if (! is_array($range)) { + $fail("The :attribute.{$axis} field must be an object."); + + return; + } + + $start = $range['start_percentage'] ?? null; + $end = $range['end_percentage'] ?? null; + + if ( + ! is_numeric($start) + || ! is_numeric($end) + || (float) $start < 0 + || (float) $end > 100 + || (float) $start >= (float) $end + ) { + $fail( + "The :attribute.{$axis} field must satisfy " + .'0 <= start_percentage < end_percentage <= 100.' + ); + } + } +} diff --git a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php index e683b72..234e754 100644 --- a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php +++ b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php @@ -29,12 +29,35 @@ class WebsiteExtraResource extends JsonResource fn (Attachment $attachment): string => $attachment->key ), 'resolved_config' => $this->formatConfig( - $this->resolvedConfig(), + $this->resolvedAdminConfig(), fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440) ), ]; } + private function resolvedAdminConfig(): mixed + { + $config = $this->resolvedConfig(); + + if ( + $this->websiteTypeExtra->codigo !== 'heroConfig' + || ! is_array($config) + || ! ($config['background_image_id'] ?? null) instanceof Attachment + ) { + return $config; + } + + $attachment = $config['background_image_id']; + $fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0]; + $config['background_image_id'] = [ + 'url' => $attachment->getTemporaryUrl(1440), + 'crop_horizontal' => $attachment->crop_horizontal ?? $fullRange, + 'crop_vertical' => $attachment->crop_vertical ?? $fullRange, + ]; + + return $config; + } + private function formatConfig(mixed $value, callable $formatAttachment): mixed { if ($value instanceof Attachment) { diff --git a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php index f1c76f2..dc6f808 100644 --- a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php +++ b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php @@ -45,14 +45,44 @@ class WebsiteExtrasResource extends JsonResource ), ]), 'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [ - $extra->websiteTypeExtra->codigo => $this->formatConfig( - $extra->resolvedConfig(), - fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440) - ), + $extra->websiteTypeExtra->codigo => $this->formatResolvedConfig($extra), ]), ]; } + private function formatResolvedConfig(mixed $extra): mixed + { + $config = $extra->resolvedConfig(); + + if ( + $extra->websiteTypeExtra->codigo === 'heroConfig' + && is_array($config) + && ($config['background_image_id'] ?? null) instanceof Attachment + ) { + $attachment = $config['background_image_id']; + $config['background_image_id'] = $this->formatHeroAttachment($attachment); + } + + return $this->formatConfig( + $config, + fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440) + ); + } + + /** + * @return array{url: string, crop_horizontal: array, crop_vertical: array} + */ + private function formatHeroAttachment(Attachment $attachment): array + { + $fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0]; + + return [ + 'url' => $attachment->getTemporaryUrl(1440), + 'crop_horizontal' => $attachment->crop_horizontal ?? $fullRange, + 'crop_vertical' => $attachment->crop_vertical ?? $fullRange, + ]; + } + private function formatConfig(mixed $value, callable $formatAttachment): mixed { if ($value instanceof Attachment) { diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index c17f4fd..b00eb5f 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -98,7 +98,7 @@ class TenantResource extends JsonResource private function formatExtraConfig(mixed $value): mixed { if ($value instanceof Attachment) { - return $value->getTemporaryUrl(1440); + return ($value->croppedAttachment ?? $value)->getTemporaryUrl(1440); } if (! is_array($value)) { diff --git a/app/Domains/Tenant/Services/TenantInformationService.php b/app/Domains/Tenant/Services/TenantInformationService.php index c9ee138..ccda228 100644 --- a/app/Domains/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Tenant/Services/TenantInformationService.php @@ -81,6 +81,7 @@ class TenantInformationService $attachments = Attachment::query() ->whereIn('id', $attachmentIds) + ->with('croppedAttachment') ->get() ->keyBy('id'); diff --git a/app/Domains/Tenant/Services/WebsiteExtraService.php b/app/Domains/Tenant/Services/WebsiteExtraService.php index 610c377..c589f33 100644 --- a/app/Domains/Tenant/Services/WebsiteExtraService.php +++ b/app/Domains/Tenant/Services/WebsiteExtraService.php @@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Services; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Services\AttachmentService; +use App\Domains\Shared\Rules\CroppedImageOrBase64Rule; use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteExtra; @@ -222,9 +223,11 @@ class WebsiteExtraService $compiled = is_string($rules) ? explode('|', $rules) : $rules; return array_map( - fn (mixed $rule): mixed => $rule === 'image_or_base64' - ? new ImageOrBase64Rule - : $rule, + fn (mixed $rule): mixed => match ($rule) { + 'image_or_base64' => new ImageOrBase64Rule, + 'cropped_image_or_base64' => new CroppedImageOrBase64Rule, + default => $rule, + }, $compiled ); } @@ -331,8 +334,12 @@ class WebsiteExtraService ); } - if (is_string($value) && Str::isUuid($value)) { - $attachment = Attachment::query()->where('key', $value)->first(); + $cropHorizontal = is_array($value) ? $value['crop_horizontal'] ?? null : null; + $cropVertical = is_array($value) ? $value['crop_vertical'] ?? null : null; + $image = is_array($value) ? $value['image'] ?? null : $value; + + if (is_string($image) && Str::isUuid($image)) { + $attachment = Attachment::query()->where('key', $image)->first(); if (! $attachment) { throw ValidationException::withMessages([ @@ -341,9 +348,24 @@ class WebsiteExtraService ], ]); } + + if (is_array($cropHorizontal) && is_array($cropVertical)) { + $attachment = $this->attachmentService->updateImageCrop( + $attachment, + $cropHorizontal, + $cropVertical, + ); + } + } elseif (is_array($cropHorizontal) && is_array($cropVertical)) { + $attachment = $this->attachmentService->storeCroppedImage( + $image, + "tenants/{$tenant->codigo}/extras/{$definition->codigo}", + $cropHorizontal, + $cropVertical, + ); } else { $attachment = $this->attachmentService->store( - $value, + $image, "tenants/{$tenant->codigo}/extras/{$definition->codigo}" ); } diff --git a/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php b/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php index c21f80c..4bf6234 100644 --- a/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php +++ b/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php @@ -9,12 +9,12 @@ return new class extends Migration public function up(): void { Schema::table('attachments', function (Blueprint $table): void { - $table->decimal('crop_horizontal_start_percent', 7, 4)->nullable()->after('size'); - $table->decimal('crop_vertical_start_percent', 7, 4)->nullable()->after('crop_horizontal_start_percent'); + $table->json('crop_horizontal')->nullable()->after('size'); + $table->json('crop_vertical')->nullable()->after('crop_horizontal'); $table->foreignId('cropped_attachment_id') ->nullable() ->unique() - ->after('crop_vertical_start_percent') + ->after('crop_vertical') ->constrained('attachments') ->nullOnDelete(); }); @@ -26,8 +26,8 @@ return new class extends Migration $table->dropForeign(['cropped_attachment_id']); $table->dropColumn([ 'cropped_attachment_id', - 'crop_horizontal_start_percent', - 'crop_vertical_start_percent', + 'crop_horizontal', + 'crop_vertical', ]); }); } diff --git a/database/migrations/2026_08_18_010000_enable_hero_image_cropping.php b/database/migrations/2026_08_18_010000_enable_hero_image_cropping.php new file mode 100644 index 0000000..d6cd61b --- /dev/null +++ b/database/migrations/2026_08_18_010000_enable_hero_image_cropping.php @@ -0,0 +1,43 @@ +replaceRule('nullable|image_or_base64', 'nullable|cropped_image_or_base64'); + } + + public function down(): void + { + $this->replaceRule('nullable|cropped_image_or_base64', 'nullable|image_or_base64'); + } + + private function replaceRule(string $from, string $to): void + { + DB::table('website_type_extras') + ->where('codigo', 'heroConfig') + ->orderBy('id') + ->each(function (object $definition) use ($from, $to): void { + $schema = json_decode((string) $definition->config_schema, true); + + if (! is_array($schema)) { + return; + } + + $currentRule = $schema['request_rules']['background_image_id'] ?? null; + + if ($currentRule !== $from) { + return; + } + + $schema['request_rules']['background_image_id'] = $to; + + DB::table('website_type_extras') + ->where('id', $definition->id) + ->update(['config_schema' => json_encode($schema)]); + }); + } +}; diff --git a/database/seeders/WebsiteTypeSeeder.php b/database/seeders/WebsiteTypeSeeder.php index 0fdd8c5..3b022a4 100644 --- a/database/seeders/WebsiteTypeSeeder.php +++ b/database/seeders/WebsiteTypeSeeder.php @@ -86,7 +86,7 @@ class WebsiteTypeSeeder extends Seeder 'description_html' => 'nullable|string', 'button_text' => 'nullable|string', 'button_href' => 'nullable|string', - 'background_image_id' => 'nullable|image_or_base64', + 'background_image_id' => 'nullable|cropped_image_or_base64', ], 'transforms' => [ 'background_image_id' => [ diff --git a/tests/Feature/Attachable/AttachmentTest.php b/tests/Feature/Attachable/AttachmentTest.php index ff24c82..193471f 100644 --- a/tests/Feature/Attachable/AttachmentTest.php +++ b/tests/Feature/Attachable/AttachmentTest.php @@ -77,15 +77,21 @@ class AttachmentTest extends TestCase $original = app(AttachmentService::class)->storeCroppedImage( UploadedFile::fake()->image('product.jpg', 200, 100), 'attachments/acme', - 50, - 25, + ['start_percentage' => 25, 'end_percentage' => 75], + ['start_percentage' => 10, 'end_percentage' => 85], ); $cropped = $original->croppedAttachment; $this->assertNotNull($cropped); - $this->assertSame(50.0, $original->crop_horizontal_start_percent); - $this->assertSame(25.0, $original->crop_vertical_start_percent); + $this->assertEquals( + ['start_percentage' => 25.0, 'end_percentage' => 75.0], + $original->crop_horizontal, + ); + $this->assertEquals( + ['start_percentage' => 10.0, 'end_percentage' => 85.0], + $original->crop_vertical, + ); $this->assertTrue($cropped->originalAttachment->is($original)); $this->assertDatabaseCount('attachments', 2); $this->assertDatabaseHas('attachments', [ @@ -110,8 +116,8 @@ class AttachmentTest extends TestCase app(AttachmentService::class)->storeCroppedImage( UploadedFile::fake()->image('product.png'), 'attachments/acme', - 100, - 0, + ['start_percentage' => 75, 'end_percentage' => 25], + ['start_percentage' => 0, 'end_percentage' => 100], ); $this->fail('Expected an AttachmentStorageException to be thrown.'); @@ -129,8 +135,8 @@ class AttachmentTest extends TestCase app(AttachmentService::class)->storeCroppedImage( UploadedFile::fake()->createWithContent('invalid.png', 'not-an-image'), 'attachments/acme', - 10, - 10, + ['start_percentage' => 10, 'end_percentage' => 90], + ['start_percentage' => 10, 'end_percentage' => 90], ); $this->fail('Expected an AttachmentStorageException to be thrown.'); @@ -147,8 +153,8 @@ class AttachmentTest extends TestCase $original = app(AttachmentService::class)->storeCroppedImage( UploadedFile::fake()->image('product.png'), 'attachments/acme', - 10, - 10, + ['start_percentage' => 10, 'end_percentage' => 90], + ['start_percentage' => 10, 'end_percentage' => 90], ); $cropped = $original->croppedAttachment; @@ -159,6 +165,37 @@ class AttachmentTest extends TestCase Storage::disk('s3')->assertMissing($cropped->path); } + public function test_it_replaces_the_crop_of_an_existing_image(): void + { + Storage::fake('s3'); + + $original = app(AttachmentService::class)->storeCroppedImage( + UploadedFile::fake()->image('product.jpg', 200, 100), + 'attachments/acme', + ['start_percentage' => 0, 'end_percentage' => 100], + ['start_percentage' => 0, 'end_percentage' => 100], + ); + $previousCrop = $original->croppedAttachment; + + $updated = app(AttachmentService::class)->updateImageCrop( + $original, + ['start_percentage' => 25, 'end_percentage' => 75], + ['start_percentage' => 10, 'end_percentage' => 85], + ); + + $this->assertFalse($updated->croppedAttachment->is($previousCrop)); + $this->assertDatabaseMissing('attachments', ['id' => $previousCrop->id]); + Storage::disk('s3')->assertMissing($previousCrop->path); + + $croppedSize = getimagesizefromstring( + Storage::disk('s3')->get($updated->croppedAttachment->path) + ); + + $this->assertIsArray($croppedSize); + $this->assertSame(100, $croppedSize[0]); + $this->assertSame(75, $croppedSize[1]); + } + public function test_it_deletes_from_s3_before_removing_the_database_record(): void { Storage::fake('s3'); diff --git a/tests/Feature/Seeders/WebsiteTypeSeederTest.php b/tests/Feature/Seeders/WebsiteTypeSeederTest.php index 5dbf1e7..c91c003 100644 --- a/tests/Feature/Seeders/WebsiteTypeSeederTest.php +++ b/tests/Feature/Seeders/WebsiteTypeSeederTest.php @@ -92,7 +92,7 @@ class WebsiteTypeSeederTest extends TestCase 'description_html' => 'nullable|string', 'button_text' => 'nullable|string', 'button_href' => 'nullable|string', - 'background_image_id' => 'nullable|image_or_base64', + 'background_image_id' => 'nullable|cropped_image_or_base64', ], 'transforms' => [ 'background_image_id' => [ diff --git a/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php b/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php index 7ce770e..05d2917 100644 --- a/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php +++ b/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php @@ -2,12 +2,15 @@ namespace Tests\Feature\Tenant; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -217,6 +220,95 @@ class AdminAppWebsiteExtraControllerTest extends TestCase ); } + public function test_adminapp_user_stores_a_cropped_banner_and_receives_original_edit_data(): void + { + Storage::fake('s3'); + $this->websiteType->extras()->create([ + 'codigo' => 'heroConfig', + 'nombre' => 'Banner principal', + 'descripcion' => 'Configuración del banner principal.', + 'is_required' => false, + 'config_schema' => [ + 'request_rules' => [ + '$' => 'required|array', + 'background_image_id' => 'nullable|cropped_image_or_base64', + ], + 'transforms' => [ + 'background_image_id' => [ + 'handler' => 'attachment', + 'attachment_type' => 'image', + ], + ], + ], + ]); + $tenant = $this->createTenant('acme'); + $file = UploadedFile::fake()->image('banner.jpg', 200, 100); + $image = 'data:image/jpeg;base64,'.base64_encode((string) file_get_contents($file->getRealPath())); + + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $response = $this->putJson('/api/v1/adminapp/tenant/website-extras/heroConfig', [ + 'config' => [ + 'background_image_id' => [ + 'image' => $image, + 'crop_horizontal' => [ + 'start_percentage' => 25, + 'end_percentage' => 75, + ], + 'crop_vertical' => [ + 'start_percentage' => 10, + 'end_percentage' => 85, + ], + ], + ], + ])->assertOk(); + + $original = Attachment::query()->whereNotNull('cropped_attachment_id')->sole(); + + $response + ->assertJsonPath('data.extras.heroConfig.background_image_id', $original->key) + ->assertJsonPath( + 'data.resolved_extras.heroConfig.background_image_id.crop_horizontal.start_percentage', + 25 + ) + ->assertJsonPath( + 'data.resolved_extras.heroConfig.background_image_id.crop_vertical.end_percentage', + 85 + ); + + $this->assertStringContainsString( + $original->key, + $response->json('data.resolved_extras.heroConfig.background_image_id.url') + ); + $this->assertDatabaseCount('attachments', 2); + + $previousCropId = $original->cropped_attachment_id; + $this->putJson('/api/v1/adminapp/tenant/website-extras/heroConfig', [ + 'config' => [ + 'background_image_id' => [ + 'image' => $original->key, + 'crop_horizontal' => [ + 'start_percentage' => 10, + 'end_percentage' => 90, + ], + 'crop_vertical' => [ + 'start_percentage' => 20, + 'end_percentage' => 80, + ], + ], + ], + ]) + ->assertOk() + ->assertJsonPath( + 'data.resolved_extras.heroConfig.background_image_id.crop_horizontal.start_percentage', + 10 + ); + + $this->assertNotSame($previousCropId, $original->refresh()->cropped_attachment_id); + $this->assertDatabaseMissing('attachments', ['id' => $previousCropId]); + $this->assertDatabaseCount('attachments', 2); + } + public function test_update_returns_not_found_for_an_unsupported_extra_code(): void { $tenant = $this->createTenant('acme'); diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index a9d8630..b99bd34 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -317,6 +317,64 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonMissingPath('data.extras.banner'); } + public function test_the_bootstrap_returns_only_the_cropped_banner_url(): void + { + Storage::fake('s3'); + $tenant = $this->createTenant(); + $websiteType = WebsiteType::query()->create([ + 'codigo' => 'event-store', + 'nombre' => 'Eventos', + ]); + $tenant->update(['website_type_code' => $websiteType->codigo]); + $heroDefinition = $websiteType->extras()->create([ + 'codigo' => 'heroConfig', + 'nombre' => 'Banner', + 'config_schema' => [ + 'transforms' => [ + 'background_image_id' => [ + 'handler' => 'attachment', + 'attachment_type' => 'image', + ], + ], + ], + ]); + $cropped = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tenants/acme/cropped.jpg', + 'filename' => 'cropped.jpg', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/jpeg', + 'extension' => 'jpg', + ]); + $original = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tenants/acme/original.jpg', + 'filename' => 'original.jpg', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/jpeg', + 'extension' => 'jpg', + 'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90], + 'crop_vertical' => ['start_percentage' => 20, 'end_percentage' => 80], + 'cropped_attachment_id' => $cropped->id, + ]); + $tenant->websiteExtras()->create([ + 'website_type_extra_id' => $heroDefinition->id, + 'config' => ['background_image_id' => $original->id], + 'is_enabled' => true, + ]); + + $response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F') + ->assertOk() + ->assertJsonMissingPath('data.extras.heroConfig.crop_horizontal') + ->assertJsonMissingPath('data.extras.heroConfig.crop_vertical'); + + $backgroundImage = $response->json('data.extras.heroConfig.background_image_id'); + + $this->assertIsString($backgroundImage); + $this->assertStringContainsString('cropped.jpg', $backgroundImage); + $this->assertStringNotContainsString('original.jpg', $backgroundImage); + } + public function test_it_returns_not_found_when_the_domain_does_not_exist(): void { $response = $this->getJson('/api/tenants/bootstrap?dominio=missing.example&path=%2F');