From aec92d5346c83b71d3119ff1b4bc9b182b4c9015 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 25 Jun 2026 15:02:52 -0300 Subject: [PATCH] feat(attachments): implement AttachmentService for managing uploads and deletions, add AttachmentStorageException for error handling --- .../Exceptions/AttachmentStorageException.php | 9 ++ .../Attachable/Services/AttachmentService.php | 97 ++++++++++++++ app/Domains/Tenant/Models/Tenant.php | 2 + tests/Feature/Attachable/AttachmentTest.php | 123 +++++++++++++++--- 4 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 app/Domains/Attachable/Exceptions/AttachmentStorageException.php create mode 100644 app/Domains/Attachable/Services/AttachmentService.php diff --git a/app/Domains/Attachable/Exceptions/AttachmentStorageException.php b/app/Domains/Attachable/Exceptions/AttachmentStorageException.php new file mode 100644 index 0000000..3919f8b --- /dev/null +++ b/app/Domains/Attachable/Exceptions/AttachmentStorageException.php @@ -0,0 +1,9 @@ +ensureAttachableExists($attachable); + + $normalizedPath = $this->normalizePath($path); + + if ($normalizedPath === '') { + throw new AttachmentStorageException('The attachment path cannot be empty.'); + } + + $filename = basename($normalizedPath); + + if ($filename === '' || $filename === '.' || $filename === DIRECTORY_SEPARATOR) { + throw new AttachmentStorageException('The attachment filename cannot be empty.'); + } + + $storedPath = Storage::disk('s3')->putFileAs( + $this->directoryFromPath($normalizedPath), + $file, + $filename, + ); + + if (! is_string($storedPath) || $storedPath === '') { + throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.'); + } + + try { + /** @var Attachment $attachment */ + $attachment = $attachable->attachments()->create([ + 'path' => $storedPath, + 'filename' => $filename, + 'type' => $type, + 'mime_type' => $file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream', + 'extension' => $file->extension(), + 'size' => $file->getSize() ?? 0, + ]); + + return $attachment; + } catch (Throwable $throwable) { + Storage::disk('s3')->delete($storedPath); + + throw $throwable; + } + } + + public function delete(Attachment $attachment): void + { + $deleted = Storage::disk('s3')->delete($attachment->path); + + if (! $deleted) { + throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.'); + } + + $attachment->delete(); + } + + protected function ensureAttachableExists(Model $attachable): void + { + if (! $attachable->exists) { + throw new AttachmentStorageException('Cannot manage attachments for an unsaved model.'); + } + } + + protected function normalizePath(string $path): string + { + return trim($path, '/'); + } + + protected function directoryFromPath(string $path): string + { + $directory = dirname($path); + + if ($directory === '.' || $directory === DIRECTORY_SEPARATOR) { + return ''; + } + + return trim($directory, '/'); + } +} diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 0139ab2..49f70e5 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -2,6 +2,7 @@ namespace App\Domains\Tenant\Models; +use App\Domains\Attachable\Models\Concerns\HasAttachments; use App\Domains\Catalog\Models\Product; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,6 +18,7 @@ use LogicException; ])] class Tenant extends Model { + use HasAttachments; use HasFactory; /** diff --git a/tests/Feature/Attachable/AttachmentTest.php b/tests/Feature/Attachable/AttachmentTest.php index 0282625..0483677 100644 --- a/tests/Feature/Attachable/AttachmentTest.php +++ b/tests/Feature/Attachable/AttachmentTest.php @@ -3,38 +3,47 @@ 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\Services\AttachmentService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Mockery; use Tests\TestCase; class AttachmentTest extends TestCase { use RefreshDatabase; - public function test_it_persists_and_casts_attachment_type(): void + public function test_it_uploads_to_s3_before_persisting_the_attachment(): void { + Storage::fake('s3'); + $tenant = Tenant::query()->create([ 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.com', ]); - $attachment = $tenant->attachments()->create([ - 'path' => 'attachments/acme/logo.png', - 'filename' => 'logo.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - 'extension' => 'png', - 'size' => 1234, - ]); + $file = UploadedFile::fake()->image('logo.png'); + + $attachment = app(AttachmentService::class)->store( + $tenant, + $file, + 'attachments/acme/logo.png', + AttachmentType::Image, + ); $this->assertSame(AttachmentType::Image, $attachment->type); - $this->assertTrue($tenant->attachments->contains($attachment)); + Storage::disk('s3')->assertExists('attachments/acme/logo.png'); $this->assertDatabaseHas('attachments', [ 'id' => $attachment->id, 'attachable_type' => $tenant->getMorphClass(), 'attachable_id' => $tenant->getKey(), + 'path' => 'attachments/acme/logo.png', + 'filename' => 'logo.png', 'type' => AttachmentType::Image->value, ]); @@ -44,7 +53,7 @@ class AttachmentTest extends TestCase $this->assertTrue($freshAttachment->attachable->is($tenant)); } - public function test_it_requires_attachment_type(): void + public function test_it_does_not_persist_the_attachment_when_the_s3_upload_fails(): void { $tenant = Tenant::query()->create([ 'codigo' => 'globex', @@ -52,16 +61,94 @@ class AttachmentTest extends TestCase 'dominio' => 'globex.com', ]); - $this->expectException(\Illuminate\Database\QueryException::class); + $disk = Mockery::mock(); + Storage::shouldReceive('disk') + ->once() + ->with('s3') + ->andReturn($disk); + $disk->shouldReceive('putFileAs') + ->once() + ->with('attachments/globex', Mockery::type(UploadedFile::class), 'manual.pdf') + ->andReturn(false); - Attachment::query()->create([ - 'attachable_type' => $tenant->getMorphClass(), - 'attachable_id' => $tenant->getKey(), - 'path' => 'attachments/globex/manual.pdf', - 'filename' => 'manual.pdf', + try { + app(AttachmentService::class)->store( + $tenant, + UploadedFile::fake()->create('manual.pdf', 10, 'application/pdf'), + 'attachments/globex/manual.pdf', + AttachmentType::Pdf, + ); + + $this->fail('Expected an AttachmentStorageException to be thrown.'); + } catch (AttachmentStorageException) { + $this->assertDatabaseCount('attachments', 0); + } + } + + public function test_it_deletes_from_s3_before_removing_the_database_record(): void + { + Storage::fake('s3'); + + $tenant = Tenant::query()->create([ + 'codigo' => 'initech', + 'nombre' => 'Initech', + 'dominio' => 'initech.com', + ]); + + Storage::disk('s3')->put('attachments/initech/spec.pdf', 'spec'); + + $attachment = $tenant->attachments()->create([ + 'path' => 'attachments/initech/spec.pdf', + 'filename' => 'spec.pdf', + 'type' => AttachmentType::Pdf, 'mime_type' => 'application/pdf', 'extension' => 'pdf', - 'size' => 9876, + 'size' => 512, + ]); + + app(AttachmentService::class)->delete($attachment); + + Storage::disk('s3')->assertMissing('attachments/initech/spec.pdf'); + $this->assertDatabaseMissing('attachments', [ + 'id' => $attachment->id, ]); } + + public function test_it_keeps_the_database_record_when_the_s3_delete_fails(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'umbrella', + 'nombre' => 'Umbrella', + 'dominio' => 'umbrella.com', + ]); + + $attachment = $tenant->attachments()->create([ + 'path' => 'attachments/umbrella/audio.mp3', + 'filename' => 'audio.mp3', + 'type' => AttachmentType::Audio, + 'mime_type' => 'audio/mpeg', + 'extension' => 'mp3', + 'size' => 1024, + ]); + + $disk = Mockery::mock(); + Storage::shouldReceive('disk') + ->once() + ->with('s3') + ->andReturn($disk); + $disk->shouldReceive('delete') + ->once() + ->with('attachments/umbrella/audio.mp3') + ->andReturn(false); + + try { + app(AttachmentService::class)->delete($attachment); + + $this->fail('Expected an AttachmentStorageException to be thrown.'); + } catch (AttachmentStorageException) { + $this->assertDatabaseHas('attachments', [ + 'id' => $attachment->id, + ]); + } + } }