diff --git a/app/Domains/Attachable/Models/Attachment.php b/app/Domains/Attachable/Models/Attachment.php index 37d80c1..350d487 100644 --- a/app/Domains/Attachable/Models/Attachment.php +++ b/app/Domains/Attachable/Models/Attachment.php @@ -6,7 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -19,9 +19,6 @@ use Illuminate\Support\Str; 'mime_type', 'extension', 'size', - 'crop_horizontal', - 'crop_vertical', - 'cropped_attachment_id', ])] class Attachment extends Model { @@ -43,20 +40,29 @@ class Attachment extends Model return [ 'type' => AttachmentType::class, 'size' => 'integer', - 'crop_horizontal' => 'array', - 'crop_vertical' => 'array', - 'cropped_attachment_id' => 'integer', ]; } - public function croppedAttachment(): BelongsTo + public function cropVariants(): HasMany { - return $this->belongsTo(self::class, 'cropped_attachment_id'); + return $this->hasMany(AttachmentCrop::class); } - public function originalAttachment(): HasOne + public function desktopCrop(): HasOne { - return $this->hasOne(self::class, 'cropped_attachment_id'); + return $this->hasOne(AttachmentCrop::class) + ->where('variant', AttachmentCrop::DESKTOP); + } + + public function mobileCrop(): HasOne + { + return $this->hasOne(AttachmentCrop::class) + ->where('variant', AttachmentCrop::MOBILE); + } + + public function cropSource(): HasOne + { + return $this->hasOne(AttachmentCrop::class, 'cropped_attachment_id'); } /** diff --git a/app/Domains/Attachable/Models/AttachmentCrop.php b/app/Domains/Attachable/Models/AttachmentCrop.php new file mode 100644 index 0000000..7487d25 --- /dev/null +++ b/app/Domains/Attachable/Models/AttachmentCrop.php @@ -0,0 +1,43 @@ + 'integer', + 'crop_horizontal' => 'array', + 'crop_vertical' => 'array', + 'cropped_attachment_id' => 'integer', + ]; + } + + public function attachment(): BelongsTo + { + return $this->belongsTo(Attachment::class); + } + + public function croppedAttachment(): BelongsTo + { + return $this->belongsTo(Attachment::class, 'cropped_attachment_id'); + } +} diff --git a/app/Domains/Attachable/Services/AttachmentService.php b/app/Domains/Attachable/Services/AttachmentService.php index 3f9c7ea..df0b72f 100644 --- a/app/Domains/Attachable/Services/AttachmentService.php +++ b/app/Domains/Attachable/Services/AttachmentService.php @@ -5,6 +5,7 @@ namespace App\Domains\Attachable\Services; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Exceptions\AttachmentStorageException; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -14,46 +15,40 @@ use Throwable; class AttachmentService { - public function storeCroppedImage( + /** + * @param array, crop_vertical: array}> $crops + */ + public function storeCroppedImageVariants( UploadedFile|string $image, string $path, - array $cropHorizontal, - array $cropVertical, + array $crops, ): Attachment { - $cropHorizontal = $this->validateCropRange($cropHorizontal, 'horizontal'); - $cropVertical = $this->validateCropRange($cropVertical, 'vertical'); - + $crops = $this->validateCropVariants($crops); $storedPaths = []; try { return DB::transaction(function () use ( $image, $path, - $cropHorizontal, - $cropVertical, + $crops, &$storedPaths, ): Attachment { $original = $this->store($image, $path); $storedPaths[] = $original->path; + $contents = $this->imageContents($image); - $croppedContents = $this->cropImage( - $this->imageContents($image), - $cropHorizontal, - $cropVertical, - ); - $cropped = $this->store( - 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), - $path, - ); - $storedPaths[] = $cropped->path; + foreach ($crops as $variant => $crop) { + $cropped = $this->storeCropVariant($contents, $path, $crop); + $storedPaths[] = $cropped->path; + $original->cropVariants()->create([ + 'variant' => $variant, + 'crop_horizontal' => $crop['crop_horizontal'], + 'crop_vertical' => $crop['crop_vertical'], + 'cropped_attachment_id' => $cropped->id, + ]); + } - $original->update([ - 'crop_horizontal' => $cropHorizontal, - 'crop_vertical' => $cropVertical, - 'cropped_attachment_id' => $cropped->id, - ]); - - return $original->load('croppedAttachment'); + return $original->load('cropVariants.croppedAttachment'); }); } catch (Throwable $throwable) { if ($storedPaths !== []) { @@ -64,49 +59,61 @@ class AttachmentService } } - public function updateImageCrop( + /** + * @param array, crop_vertical: array}> $crops + */ + public function updateImageCropVariants( Attachment $original, - array $cropHorizontal, - array $cropVertical, + array $crops, ): 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'); + $crops = $this->validateCropVariants($crops); $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, - ); + $previousCrops = $original->cropVariants()->with('croppedAttachment')->get(); + $storedCrops = []; try { - $original->update([ - 'crop_horizontal' => $cropHorizontal, - 'crop_vertical' => $cropVertical, - 'cropped_attachment_id' => $cropped->id, - ]); + foreach ($crops as $variant => $crop) { + $storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop); + } + + DB::transaction(function () use ($original, $crops, $storedCrops): void { + foreach ($crops as $variant => $crop) { + $original->cropVariants()->updateOrCreate( + ['variant' => $variant], + [ + 'crop_horizontal' => $crop['crop_horizontal'], + 'crop_vertical' => $crop['crop_vertical'], + 'cropped_attachment_id' => $storedCrops[$variant]->id, + ] + ); + } + }); } catch (Throwable $throwable) { - $this->delete($cropped); + foreach ($storedCrops as $storedCrop) { + $this->delete($storedCrop); + } throw $throwable; } - if ($previousCrop !== null && ! $previousCrop->is($cropped)) { - $this->delete($previousCrop); + foreach ($previousCrops as $previousCrop) { + if ($previousCrop->croppedAttachment !== null) { + $this->delete($previousCrop->croppedAttachment); + } } - return $original->refresh()->load('croppedAttachment'); + return $original->refresh()->load('cropVariants.croppedAttachment'); } public function store( @@ -153,11 +160,18 @@ class AttachmentService public function delete(Attachment $attachment): void { - $croppedAttachment = $attachment->croppedAttachment; - $paths = array_values(array_filter([ - $attachment->path, - $croppedAttachment?->path, - ])); + $croppedAttachments = $attachment->cropVariants() + ->with('croppedAttachment') + ->get() + ->pluck('croppedAttachment') + ->filter(); + $paths = $croppedAttachments + ->pluck('path') + ->prepend($attachment->path) + ->filter() + ->unique() + ->values() + ->all(); $deleted = Storage::disk('s3')->delete( count($paths) === 1 ? $paths[0] : $paths ); @@ -166,9 +180,9 @@ class AttachmentService throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.'); } - DB::transaction(function () use ($attachment, $croppedAttachment): void { + DB::transaction(function () use ($attachment, $croppedAttachments): void { $attachment->delete(); - $croppedAttachment?->delete(); + Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete(); }); } @@ -213,6 +227,56 @@ class AttachmentService return trim($path, '/'); } + /** + * @param array $crops + * @return array + */ + protected function validateCropVariants(array $crops): array + { + $validated = []; + + foreach (AttachmentCrop::VARIANTS as $variant) { + $crop = $crops[$variant] ?? null; + + if (! is_array($crop)) { + throw new AttachmentStorageException("The {$variant} crop is required."); + } + + $horizontal = $crop['crop_horizontal'] ?? null; + $vertical = $crop['crop_vertical'] ?? null; + + if (! is_array($horizontal) || ! is_array($vertical)) { + throw new AttachmentStorageException( + "The {$variant} crop must contain crop_horizontal and crop_vertical ranges." + ); + } + + $validated[$variant] = [ + 'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"), + 'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"), + ]; + } + + return $validated; + } + + /** + * @param array{crop_horizontal: array, crop_vertical: array} $crop + */ + protected function storeCropVariant(string $contents, string $path, array $crop): Attachment + { + $croppedContents = $this->cropImage( + $contents, + $crop['crop_horizontal'], + $crop['crop_vertical'], + ); + + return $this->store( + 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), + $path, + ); + } + /** * @param array{start_percentage?: mixed, end_percentage?: mixed} $range * @return array{start_percentage: float, end_percentage: float} diff --git a/app/Domains/Attachable/documentacion/README.md b/app/Domains/Attachable/documentacion/README.md index 6f28780..857fec4 100644 --- a/app/Domains/Attachable/documentacion/README.md +++ b/app/Domains/Attachable/documentacion/README.md @@ -2,11 +2,12 @@ ## Propósito -Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con una versión recortada desde porcentajes horizontales y verticales. +Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con variantes recortadas para desktop y mobile. ## Componentes principales - `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso. +- `Models/AttachmentCrop.php`: relaciona un original con su crop desktop o mobile y la imagen generada. - `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos. - `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos. - `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento. @@ -26,7 +27,7 @@ No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog - El directorio no puede quedar vacío después de normalizarlo. - 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. +- `attachment_crops` guarda una fila por variante con los rangos horizontal/vertical y el attachment procesado. +- Al eliminar el original mediante el servicio también se eliminan todas sus variantes recortadas. - 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 index a7b932b..e26f47e 100644 --- a/app/Domains/Shared/Rules/CroppedImageOrBase64Rule.php +++ b/app/Domains/Shared/Rules/CroppedImageOrBase64Rule.php @@ -35,6 +35,24 @@ class CroppedImageOrBase64Rule implements ValidationRule return; } + if (isset($value['crops']) && is_array($value['crops'])) { + foreach (['desktop', 'mobile'] as $variant) { + $crop = $value['crops'][$variant] ?? null; + + if (! is_array($crop)) { + $fail("The :attribute.crops.{$variant} field must be an object."); + + continue; + } + + $this->validateRange("{$attribute}.crops.{$variant}", 'crop_horizontal', $crop, $fail); + $this->validateRange("{$attribute}.crops.{$variant}", 'crop_vertical', $crop, $fail); + } + + return; + } + + // Backwards compatibility with the original single-crop payload. $this->validateRange($attribute, 'crop_horizontal', $value, $fail); $this->validateRange($attribute, 'crop_vertical', $value, $fail); } diff --git a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php index 234e754..49222a9 100644 --- a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php +++ b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtraResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Tenant\Resources\AdminApp; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Tenant\Models\WebsiteExtra; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -49,10 +50,19 @@ class WebsiteExtraResource extends JsonResource $attachment = $config['background_image_id']; $fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0]; + $crops = $attachment->cropVariants->keyBy('variant'); $config['background_image_id'] = [ 'url' => $attachment->getTemporaryUrl(1440), - 'crop_horizontal' => $attachment->crop_horizontal ?? $fullRange, - 'crop_vertical' => $attachment->crop_vertical ?? $fullRange, + 'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys( + function (string $variant) use ($crops, $fullRange): array { + $crop = $crops->get($variant); + + return [$variant => [ + 'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange, + 'crop_vertical' => $crop?->crop_vertical ?? $fullRange, + ]]; + } + )->all(), ]; return $config; diff --git a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php index dc6f808..4a8a1b5 100644 --- a/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php +++ b/app/Domains/Tenant/Resources/AdminApp/WebsiteExtrasResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Tenant\Resources\AdminApp; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -70,16 +71,25 @@ class WebsiteExtrasResource extends JsonResource } /** - * @return array{url: string, crop_horizontal: array, crop_vertical: array} + * @return array{url: string, crops: array>>} */ private function formatHeroAttachment(Attachment $attachment): array { $fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0]; + $crops = $attachment->cropVariants->keyBy('variant'); return [ 'url' => $attachment->getTemporaryUrl(1440), - 'crop_horizontal' => $attachment->crop_horizontal ?? $fullRange, - 'crop_vertical' => $attachment->crop_vertical ?? $fullRange, + 'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys( + function (string $variant) use ($crops, $fullRange): array { + $crop = $crops->get($variant); + + return [$variant => [ + 'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange, + 'crop_vertical' => $crop?->crop_vertical ?? $fullRange, + ]]; + } + )->all(), ]; } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index b00eb5f..08f92c4 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Tenant\Resources; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; @@ -98,7 +99,18 @@ class TenantResource extends JsonResource private function formatExtraConfig(mixed $value): mixed { if ($value instanceof Attachment) { - return ($value->croppedAttachment ?? $value)->getTemporaryUrl(1440); + $crops = $value->cropVariants->keyBy('variant'); + $desktop = $crops->get(AttachmentCrop::DESKTOP)?->croppedAttachment ?? $value; + $mobile = $crops->get(AttachmentCrop::MOBILE)?->croppedAttachment ?? $desktop; + + if ($crops->isEmpty()) { + return $value->getTemporaryUrl(1440); + } + + return [ + 'desktop' => $desktop->getTemporaryUrl(1440), + 'mobile' => $mobile->getTemporaryUrl(1440), + ]; } if (! is_array($value)) { diff --git a/app/Domains/Tenant/Services/TenantInformationService.php b/app/Domains/Tenant/Services/TenantInformationService.php index ccda228..184d266 100644 --- a/app/Domains/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Tenant/Services/TenantInformationService.php @@ -81,7 +81,7 @@ class TenantInformationService $attachments = Attachment::query() ->whereIn('id', $attachmentIds) - ->with('croppedAttachment') + ->with('cropVariants.croppedAttachment') ->get() ->keyBy('id'); diff --git a/app/Domains/Tenant/Services/WebsiteExtraService.php b/app/Domains/Tenant/Services/WebsiteExtraService.php index c589f33..a86a4aa 100644 --- a/app/Domains/Tenant/Services/WebsiteExtraService.php +++ b/app/Domains/Tenant/Services/WebsiteExtraService.php @@ -334,8 +334,7 @@ class WebsiteExtraService ); } - $cropHorizontal = is_array($value) ? $value['crop_horizontal'] ?? null : null; - $cropVertical = is_array($value) ? $value['crop_vertical'] ?? null : null; + $crops = $this->cropVariants($value); $image = is_array($value) ? $value['image'] ?? null : $value; if (is_string($image) && Str::isUuid($image)) { @@ -349,19 +348,17 @@ class WebsiteExtraService ]); } - if (is_array($cropHorizontal) && is_array($cropVertical)) { - $attachment = $this->attachmentService->updateImageCrop( + if ($crops !== null) { + $attachment = $this->attachmentService->updateImageCropVariants( $attachment, - $cropHorizontal, - $cropVertical, + $crops, ); } - } elseif (is_array($cropHorizontal) && is_array($cropVertical)) { - $attachment = $this->attachmentService->storeCroppedImage( + } elseif ($crops !== null) { + $attachment = $this->attachmentService->storeCroppedImageVariants( $image, "tenants/{$tenant->codigo}/extras/{$definition->codigo}", - $cropHorizontal, - $cropVertical, + $crops, ); } else { $attachment = $this->attachmentService->store( @@ -386,4 +383,37 @@ class WebsiteExtraService return $attachment->id; } + + /** + * Normalize the current variants payload and the original single-crop contract. + * + * @return array>|null + */ + private function cropVariants(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + if (isset($value['crops']) && is_array($value['crops'])) { + return $value['crops']; + } + + $horizontal = $value['crop_horizontal'] ?? null; + $vertical = $value['crop_vertical'] ?? null; + + if (! is_array($horizontal) || ! is_array($vertical)) { + return null; + } + + $crop = [ + 'crop_horizontal' => $horizontal, + 'crop_vertical' => $vertical, + ]; + + return [ + 'desktop' => $crop, + 'mobile' => $crop, + ]; + } } diff --git a/database/migrations/2026_08_18_030000_create_attachment_crops_table.php b/database/migrations/2026_08_18_030000_create_attachment_crops_table.php new file mode 100644 index 0000000..6f5779a --- /dev/null +++ b/database/migrations/2026_08_18_030000_create_attachment_crops_table.php @@ -0,0 +1,91 @@ +id(); + $table->foreignId('attachment_id')->constrained('attachments')->cascadeOnDelete(); + $table->string('variant', 20); + $table->json('crop_horizontal'); + $table->json('crop_vertical'); + $table->foreignId('cropped_attachment_id') + ->unique() + ->constrained('attachments') + ->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['attachment_id', 'variant']); + }); + } + + if (Schema::hasColumn('attachments', 'cropped_attachment_id')) { + DB::table('attachments') + ->whereNotNull('cropped_attachment_id') + ->orderBy('id') + ->chunkById(100, function ($attachments): void { + foreach ($attachments as $attachment) { + DB::table('attachment_crops')->updateOrInsert( + ['attachment_id' => $attachment->id, 'variant' => 'desktop'], + [ + 'crop_horizontal' => $attachment->crop_horizontal + ?? json_encode(['start_percentage' => 0, 'end_percentage' => 100]), + 'crop_vertical' => $attachment->crop_vertical + ?? json_encode(['start_percentage' => 0, 'end_percentage' => 100]), + 'cropped_attachment_id' => $attachment->cropped_attachment_id, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + }); + + Schema::table('attachments', function (Blueprint $table): void { + $table->dropForeign(['cropped_attachment_id']); + $table->dropColumn([ + 'crop_horizontal', + 'crop_vertical', + 'cropped_attachment_id', + ]); + }); + } + } + + public function down(): void + { + Schema::table('attachments', function (Blueprint $table): void { + $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') + ->constrained('attachments') + ->nullOnDelete(); + }); + + DB::table('attachment_crops') + ->where('variant', 'desktop') + ->orderBy('id') + ->chunkById(100, function ($crops): void { + foreach ($crops as $crop) { + DB::table('attachments') + ->where('id', $crop->attachment_id) + ->update([ + 'crop_horizontal' => $crop->crop_horizontal, + 'crop_vertical' => $crop->crop_vertical, + 'cropped_attachment_id' => $crop->cropped_attachment_id, + ]); + } + }); + + Schema::dropIfExists('attachment_crops'); + } +}; diff --git a/tests/Feature/Attachable/AttachmentTest.php b/tests/Feature/Attachable/AttachmentTest.php index 193471f..4cdf184 100644 --- a/tests/Feature/Attachable/AttachmentTest.php +++ b/tests/Feature/Attachable/AttachmentTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Attachable; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Exceptions\AttachmentStorageException; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Attachable\Services\AttachmentService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -74,32 +75,45 @@ class AttachmentTest extends TestCase { Storage::fake('s3'); - $original = app(AttachmentService::class)->storeCroppedImage( + $original = app(AttachmentService::class)->storeCroppedImageVariants( UploadedFile::fake()->image('product.jpg', 200, 100), 'attachments/acme', - ['start_percentage' => 25, 'end_percentage' => 75], - ['start_percentage' => 10, 'end_percentage' => 85], + [ + 'desktop' => [ + 'crop_horizontal' => ['start_percentage' => 25, 'end_percentage' => 75], + 'crop_vertical' => ['start_percentage' => 10, 'end_percentage' => 85], + ], + 'mobile' => [ + 'crop_horizontal' => ['start_percentage' => 20, 'end_percentage' => 80], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ], + ], ); - $cropped = $original->croppedAttachment; + $desktopCrop = $original->cropVariants->firstWhere('variant', AttachmentCrop::DESKTOP); + $mobileCrop = $original->cropVariants->firstWhere('variant', AttachmentCrop::MOBILE); + $cropped = $desktopCrop->croppedAttachment; $this->assertNotNull($cropped); $this->assertEquals( ['start_percentage' => 25.0, 'end_percentage' => 75.0], - $original->crop_horizontal, + $desktopCrop->crop_horizontal, ); $this->assertEquals( ['start_percentage' => 10.0, 'end_percentage' => 85.0], - $original->crop_vertical, + $desktopCrop->crop_vertical, ); - $this->assertTrue($cropped->originalAttachment->is($original)); - $this->assertDatabaseCount('attachments', 2); - $this->assertDatabaseHas('attachments', [ - 'id' => $original->id, + $this->assertTrue($cropped->cropSource->attachment->is($original)); + $this->assertDatabaseCount('attachments', 3); + $this->assertDatabaseCount('attachment_crops', 2); + $this->assertDatabaseHas('attachment_crops', [ + 'attachment_id' => $original->id, + 'variant' => AttachmentCrop::DESKTOP, 'cropped_attachment_id' => $cropped->id, ]); Storage::disk('s3')->assertExists($original->path); Storage::disk('s3')->assertExists($cropped->path); + Storage::disk('s3')->assertExists($mobileCrop->croppedAttachment->path); $croppedSize = getimagesizefromstring(Storage::disk('s3')->get($cropped->path)); @@ -113,11 +127,19 @@ class AttachmentTest extends TestCase Storage::fake('s3'); try { - app(AttachmentService::class)->storeCroppedImage( + app(AttachmentService::class)->storeCroppedImageVariants( UploadedFile::fake()->image('product.png'), 'attachments/acme', - ['start_percentage' => 75, 'end_percentage' => 25], - ['start_percentage' => 0, 'end_percentage' => 100], + [ + 'desktop' => [ + 'crop_horizontal' => ['start_percentage' => 75, 'end_percentage' => 25], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ], + 'mobile' => [ + 'crop_horizontal' => ['start_percentage' => 0, 'end_percentage' => 100], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ], + ], ); $this->fail('Expected an AttachmentStorageException to be thrown.'); @@ -132,11 +154,10 @@ class AttachmentTest extends TestCase Storage::fake('s3'); try { - app(AttachmentService::class)->storeCroppedImage( + app(AttachmentService::class)->storeCroppedImageVariants( UploadedFile::fake()->createWithContent('invalid.png', 'not-an-image'), 'attachments/acme', - ['start_percentage' => 10, 'end_percentage' => 90], - ['start_percentage' => 10, 'end_percentage' => 90], + $this->fullCropVariants(), ); $this->fail('Expected an AttachmentStorageException to be thrown.'); @@ -150,45 +171,56 @@ class AttachmentTest extends TestCase { Storage::fake('s3'); - $original = app(AttachmentService::class)->storeCroppedImage( + $original = app(AttachmentService::class)->storeCroppedImageVariants( UploadedFile::fake()->image('product.png'), 'attachments/acme', - ['start_percentage' => 10, 'end_percentage' => 90], - ['start_percentage' => 10, 'end_percentage' => 90], + $this->fullCropVariants(), ); - $cropped = $original->croppedAttachment; + $cropped = $original->cropVariants->pluck('croppedAttachment'); app(AttachmentService::class)->delete($original); $this->assertDatabaseCount('attachments', 0); Storage::disk('s3')->assertMissing($original->path); - Storage::disk('s3')->assertMissing($cropped->path); + foreach ($cropped as $variant) { + Storage::disk('s3')->assertMissing($variant->path); + } } public function test_it_replaces_the_crop_of_an_existing_image(): void { Storage::fake('s3'); - $original = app(AttachmentService::class)->storeCroppedImage( + $original = app(AttachmentService::class)->storeCroppedImageVariants( UploadedFile::fake()->image('product.jpg', 200, 100), 'attachments/acme', - ['start_percentage' => 0, 'end_percentage' => 100], - ['start_percentage' => 0, 'end_percentage' => 100], + $this->fullCropVariants(), ); - $previousCrop = $original->croppedAttachment; + $previousCrops = $original->cropVariants->pluck('croppedAttachment'); - $updated = app(AttachmentService::class)->updateImageCrop( + $updated = app(AttachmentService::class)->updateImageCropVariants( $original, - ['start_percentage' => 25, 'end_percentage' => 75], - ['start_percentage' => 10, 'end_percentage' => 85], + [ + 'desktop' => [ + 'crop_horizontal' => ['start_percentage' => 25, 'end_percentage' => 75], + 'crop_vertical' => ['start_percentage' => 10, 'end_percentage' => 85], + ], + 'mobile' => [ + 'crop_horizontal' => ['start_percentage' => 30, 'end_percentage' => 70], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ], + ], ); - $this->assertFalse($updated->croppedAttachment->is($previousCrop)); - $this->assertDatabaseMissing('attachments', ['id' => $previousCrop->id]); - Storage::disk('s3')->assertMissing($previousCrop->path); + foreach ($previousCrops as $previousCrop) { + $this->assertDatabaseMissing('attachments', ['id' => $previousCrop->id]); + Storage::disk('s3')->assertMissing($previousCrop->path); + } + + $desktop = $updated->cropVariants->firstWhere('variant', AttachmentCrop::DESKTOP); $croppedSize = getimagesizefromstring( - Storage::disk('s3')->get($updated->croppedAttachment->path) + Storage::disk('s3')->get($desktop->croppedAttachment->path) ); $this->assertIsArray($croppedSize); @@ -252,4 +284,14 @@ class AttachmentTest extends TestCase ]); } } + + private function fullCropVariants(): array + { + $crop = [ + 'crop_horizontal' => ['start_percentage' => 0, 'end_percentage' => 100], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ]; + + return ['desktop' => $crop, 'mobile' => $crop]; + } } diff --git a/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php b/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php index 05d2917..2cb8b83 100644 --- a/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php +++ b/tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Tenant; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Tenant\Models\Tenant; @@ -251,62 +252,98 @@ class AdminAppWebsiteExtraControllerTest extends TestCase 'config' => [ 'background_image_id' => [ 'image' => $image, - 'crop_horizontal' => [ - 'start_percentage' => 25, - 'end_percentage' => 75, - ], - 'crop_vertical' => [ - 'start_percentage' => 10, - 'end_percentage' => 85, + 'crops' => [ + 'desktop' => [ + 'crop_horizontal' => [ + 'start_percentage' => 25, + 'end_percentage' => 75, + ], + 'crop_vertical' => [ + 'start_percentage' => 10, + 'end_percentage' => 85, + ], + ], + 'mobile' => [ + 'crop_horizontal' => [ + 'start_percentage' => 30, + 'end_percentage' => 70, + ], + 'crop_vertical' => [ + 'start_percentage' => 0, + 'end_percentage' => 100, + ], + ], ], ], ], ])->assertOk(); - $original = Attachment::query()->whereNotNull('cropped_attachment_id')->sole(); + $original = Attachment::query()->whereHas('cropVariants')->sole(); $response ->assertJsonPath('data.extras.heroConfig.background_image_id', $original->key) ->assertJsonPath( - 'data.resolved_extras.heroConfig.background_image_id.crop_horizontal.start_percentage', + 'data.resolved_extras.heroConfig.background_image_id.crops.desktop.crop_horizontal.start_percentage', 25 ) ->assertJsonPath( - 'data.resolved_extras.heroConfig.background_image_id.crop_vertical.end_percentage', - 85 + 'data.resolved_extras.heroConfig.background_image_id.crops.mobile.crop_vertical.end_percentage', + 100 ); $this->assertStringContainsString( $original->key, $response->json('data.resolved_extras.heroConfig.background_image_id.url') ); - $this->assertDatabaseCount('attachments', 2); + $this->assertDatabaseCount('attachments', 3); + $this->assertDatabaseCount('attachment_crops', 2); - $previousCropId = $original->cropped_attachment_id; + $previousCropIds = $original->cropVariants()->pluck('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, + 'crops' => [ + 'desktop' => [ + 'crop_horizontal' => [ + 'start_percentage' => 10, + 'end_percentage' => 90, + ], + 'crop_vertical' => [ + 'start_percentage' => 20, + 'end_percentage' => 80, + ], + ], + 'mobile' => [ + 'crop_horizontal' => [ + 'start_percentage' => 35, + 'end_percentage' => 65, + ], + 'crop_vertical' => [ + 'start_percentage' => 0, + 'end_percentage' => 100, + ], + ], ], ], ], ]) ->assertOk() ->assertJsonPath( - 'data.resolved_extras.heroConfig.background_image_id.crop_horizontal.start_percentage', + 'data.resolved_extras.heroConfig.background_image_id.crops.desktop.crop_horizontal.start_percentage', 10 ); - $this->assertNotSame($previousCropId, $original->refresh()->cropped_attachment_id); - $this->assertDatabaseMissing('attachments', ['id' => $previousCropId]); - $this->assertDatabaseCount('attachments', 2); + $newCropIds = $original->cropVariants()->pluck('cropped_attachment_id'); + $this->assertEmpty($previousCropIds->intersect($newCropIds)); + foreach ($previousCropIds as $previousCropId) { + $this->assertDatabaseMissing('attachments', ['id' => $previousCropId]); + } + $this->assertDatabaseCount('attachments', 3); + $this->assertDatabaseHas('attachment_crops', [ + 'attachment_id' => $original->id, + 'variant' => AttachmentCrop::MOBILE, + ]); } public function test_update_returns_not_found_for_an_unsupported_extra_code(): void diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index b99bd34..3bdea04 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Tenant; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Role; use App\Domains\Catalog\Models\Category; @@ -338,10 +339,18 @@ class BootstrapTenantControllerTest extends TestCase ], ], ]); - $cropped = Attachment::query()->create([ + $desktop = Attachment::query()->create([ 'key' => (string) Str::uuid(), - 'path' => 'tenants/acme/cropped.jpg', - 'filename' => 'cropped.jpg', + 'path' => 'tenants/acme/desktop.jpg', + 'filename' => 'desktop.jpg', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/jpeg', + 'extension' => 'jpg', + ]); + $mobile = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tenants/acme/mobile.jpg', + 'filename' => 'mobile.jpg', 'type' => AttachmentType::Image, 'mime_type' => 'image/jpeg', 'extension' => 'jpg', @@ -353,10 +362,15 @@ class BootstrapTenantControllerTest extends TestCase '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, ]); + foreach ([AttachmentCrop::DESKTOP => $desktop, AttachmentCrop::MOBILE => $mobile] as $variant => $crop) { + $original->cropVariants()->create([ + 'variant' => $variant, + 'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90], + 'crop_vertical' => ['start_percentage' => 20, 'end_percentage' => 80], + 'cropped_attachment_id' => $crop->id, + ]); + } $tenant->websiteExtras()->create([ 'website_type_extra_id' => $heroDefinition->id, 'config' => ['background_image_id' => $original->id], @@ -368,11 +382,9 @@ class BootstrapTenantControllerTest extends TestCase ->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); + $response + ->assertJsonPath('data.extras.heroConfig.background_image_id.desktop', fn (string $url): bool => str_contains($url, 'desktop.jpg')) + ->assertJsonPath('data.extras.heroConfig.background_image_id.mobile', fn (string $url): bool => str_contains($url, 'mobile.jpg')); } public function test_it_returns_not_found_when_the_domain_does_not_exist(): void