Compare commits
10 Commits
3360e81e96
...
feature/at
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce21799c7 | |||
| 17799122e2 | |||
| e4448d810e | |||
| 7994fe0c96 | |||
| ee59236d55 | |||
| aec92d5346 | |||
| 6db98ddc1a | |||
| 56b87f0c12 | |||
| 9c5a29d933 | |||
| 049484dee5 |
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Enums;
|
||||
|
||||
enum AttachmentType: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
case Pdf = 'pdf';
|
||||
case Document = 'document';
|
||||
case Audio = 'audio';
|
||||
case Other = 'other';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class AttachmentStorageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
44
app/Domains/Attachable/Models/AttachableAttachment.php
Normal file
44
app/Domains/Attachable/Models/AttachableAttachment.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
#[Fillable([
|
||||
'attachable_type',
|
||||
'attachable_id',
|
||||
'attachment_id',
|
||||
])]
|
||||
class AttachableAttachment extends Model
|
||||
{
|
||||
protected $table = 'attachable_attachments';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attachable_id' => 'integer',
|
||||
'attachment_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function attachable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
public function attachment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'attachment_id');
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
namespace App\Domains\Attachable\Models;
|
||||
|
||||
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\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'attachable_type',
|
||||
'attachable_id',
|
||||
'key',
|
||||
'path',
|
||||
'filename',
|
||||
'type',
|
||||
@@ -23,19 +24,28 @@ class Attachment extends Model
|
||||
|
||||
protected $table = 'attachments';
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $attachment): void {
|
||||
if (! $attachment->key) {
|
||||
$attachment->key = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attachable_id' => 'integer',
|
||||
'type' => AttachmentType::class,
|
||||
'size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
* @return HasMany<AttachableAttachment, $this>
|
||||
*/
|
||||
public function attachable(): MorphTo
|
||||
public function attachables(): HasMany
|
||||
{
|
||||
return $this->morphTo();
|
||||
return $this->hasMany(AttachableAttachment::class, 'attachment_id');
|
||||
}
|
||||
}
|
||||
|
||||
24
app/Domains/Attachable/Models/Concerns/HasAttachments.php
Normal file
24
app/Domains/Attachable/Models/Concerns/HasAttachments.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Models\Concerns;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
|
||||
trait HasAttachments
|
||||
{
|
||||
/**
|
||||
* @return MorphToMany<Attachment, Model, $this>
|
||||
*/
|
||||
public function attachments(): MorphToMany
|
||||
{
|
||||
return $this->morphToMany(
|
||||
Attachment::class,
|
||||
'attachable',
|
||||
'attachable_attachments',
|
||||
'attachable_id',
|
||||
'attachment_id',
|
||||
);
|
||||
}
|
||||
}
|
||||
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Attachable\Services;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
use Throwable;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
public function store(
|
||||
UploadedFile|string $file,
|
||||
string $path,
|
||||
): Attachment {
|
||||
$normalizedPath = $this->normalizeDirectory($path);
|
||||
|
||||
if ($normalizedPath === '') {
|
||||
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
||||
}
|
||||
|
||||
$key = (string) Str::uuid();
|
||||
$fileData = $this->resolveFileData($file, $key);
|
||||
$storedPath = $this->storeFile(
|
||||
$fileData,
|
||||
$normalizedPath,
|
||||
$this->buildStoredFilename($key, $fileData['extension']),
|
||||
);
|
||||
|
||||
if (! is_string($storedPath) || $storedPath === '') {
|
||||
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->create([
|
||||
'key' => $key,
|
||||
'path' => $storedPath,
|
||||
'filename' => $this->resolveFilename($file, $key),
|
||||
'type' => $this->resolveAttachmentType($fileData['mime_type']),
|
||||
'mime_type' => $fileData['mime_type'],
|
||||
'extension' => $fileData['extension'],
|
||||
'size' => $fileData['size'],
|
||||
]);
|
||||
|
||||
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 normalizeDirectory(string $path): string
|
||||
{
|
||||
return trim($path, '/');
|
||||
}
|
||||
|
||||
protected function resolveFilename(UploadedFile|string $file, string $key): string
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
$originalName = trim($file->getClientOriginalName());
|
||||
|
||||
if ($originalName !== '') {
|
||||
return $originalName;
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
|
||||
*/
|
||||
protected function resolveFileData(UploadedFile|string $file, string $filename): array
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
return $this->fileDataFromUploadedFile($file);
|
||||
}
|
||||
|
||||
return $this->fileDataFromBase64($file, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
|
||||
*/
|
||||
protected function fileDataFromUploadedFile(UploadedFile $file): array
|
||||
{
|
||||
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
|
||||
|
||||
return [
|
||||
'contents' => null,
|
||||
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
|
||||
'file' => $file,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $file->getSize() ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
|
||||
*/
|
||||
protected function fileDataFromBase64(string $file, string $filename): array
|
||||
{
|
||||
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
|
||||
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'extension' => $this->resolveExtension($filename, $mimeType),
|
||||
'file' => null,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => strlen($contents),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data: string, mime_type: string|null}
|
||||
*/
|
||||
protected function decodeBase64File(string $file): array
|
||||
{
|
||||
$payload = trim($file);
|
||||
|
||||
if ($payload === '') {
|
||||
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
|
||||
}
|
||||
|
||||
$declaredMimeType = null;
|
||||
|
||||
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||
$declaredMimeType = strtolower($matches['mime']);
|
||||
$payload = $matches['data'];
|
||||
}
|
||||
|
||||
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||
|
||||
if ($decoded === false || $decoded === '') {
|
||||
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $decoded,
|
||||
'mime_type' => $declaredMimeType,
|
||||
];
|
||||
}
|
||||
|
||||
protected function detectMimeType(string $contents, ?string $fallback = null): string
|
||||
{
|
||||
if (is_string($fallback) && $fallback !== '') {
|
||||
return strtolower($fallback);
|
||||
}
|
||||
|
||||
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
|
||||
|
||||
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
|
||||
return strtolower($detectedMimeType);
|
||||
}
|
||||
|
||||
return strtolower($fallback ?? 'application/octet-stream');
|
||||
}
|
||||
|
||||
protected function resolveExtension(string $filename, string $mimeType): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension !== '') {
|
||||
return $extension;
|
||||
}
|
||||
|
||||
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
|
||||
*/
|
||||
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
|
||||
{
|
||||
if ($fileData['file'] instanceof UploadedFile) {
|
||||
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
|
||||
}
|
||||
|
||||
$storedPath = $directory !== ''
|
||||
? $directory.'/'.$storedFilename
|
||||
: $storedFilename;
|
||||
|
||||
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
|
||||
|
||||
if (! $stored) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $storedPath;
|
||||
}
|
||||
|
||||
protected function resolveAttachmentType(string $mimeType): AttachmentType
|
||||
{
|
||||
$mimeType = strtolower($mimeType);
|
||||
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
return AttachmentType::Image;
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'video/')) {
|
||||
return AttachmentType::Video;
|
||||
}
|
||||
|
||||
if ($mimeType === 'application/pdf') {
|
||||
return AttachmentType::Pdf;
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'audio/')) {
|
||||
return AttachmentType::Audio;
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($mimeType, 'text/')
|
||||
|| str_contains($mimeType, 'document')
|
||||
|| str_contains($mimeType, 'word')
|
||||
|| str_contains($mimeType, 'excel')
|
||||
|| str_contains($mimeType, 'spreadsheet')
|
||||
|| str_contains($mimeType, 'presentation')
|
||||
|| str_contains($mimeType, 'officedocument')
|
||||
) {
|
||||
return AttachmentType::Document;
|
||||
}
|
||||
|
||||
return AttachmentType::Other;
|
||||
}
|
||||
|
||||
protected function buildStoredFilename(string $key, string $extension): string
|
||||
{
|
||||
if ($extension === '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $key.'.'.$extension;
|
||||
}
|
||||
}
|
||||
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Controllers;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
||||
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
||||
use App\Domains\StorageTest\Services\S3TestService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class S3TestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected S3TestService $s3TestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(StoreS3TestFileRequest $request): JsonResponse
|
||||
{
|
||||
$attachment = $this->attachmentService->store(
|
||||
$request->file('file') ?? (string) $request->validated('file_base64'),
|
||||
$request->validated('path'),
|
||||
);
|
||||
|
||||
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
||||
$attachment->path,
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'id' => $attachment->id,
|
||||
'key' => $attachment->key,
|
||||
'path' => $attachment->path,
|
||||
'filename' => $attachment->filename,
|
||||
'type' => $attachment->type->value,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'extension' => $attachment->extension,
|
||||
'size' => $attachment->size,
|
||||
'temporary_url' => $temporaryUrl['temporary_url'],
|
||||
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
||||
],
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->s3TestService->generateTemporaryUrl(
|
||||
$request->validated('path'),
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateS3TemporaryUrlRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreS3TestFileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
||||
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function storeTestFile(
|
||||
UploadedFile $file,
|
||||
?string $directory = null,
|
||||
int $expiresInMinutes = 10,
|
||||
): array {
|
||||
$directory = $this->normalizeDirectory($directory);
|
||||
$disk = Storage::disk('s3');
|
||||
$path = $disk->putFile($directory, $file);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
Log::error('S3 upload returned an empty path.', [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'filename' => basename($path),
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||
{
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
if ($directory !== '') {
|
||||
return $directory;
|
||||
}
|
||||
|
||||
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||
}
|
||||
}
|
||||
9
app/Domains/StorageTest/routes/api.php
Normal file
9
app/Domains/StorageTest/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('storage-test/s3')->group(function (): void {
|
||||
Route::post('upload', [S3TestController::class, 'store']);
|
||||
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('carritos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
||||
$table->string('guest_token')->nullable()->index();
|
||||
$table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('carritos');
|
||||
}
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('carrito_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('producto_variante_id');
|
||||
$table->unsignedInteger('cantidad');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('producto_variante_id')
|
||||
->references('id')
|
||||
->on('productos_variantes')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['cart_id', 'producto_variante_id'], 'cart_item_cart_variant_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('carrito_items');
|
||||
}
|
||||
};
|
||||
@@ -15,9 +15,10 @@ return new class extends Migration
|
||||
$table->id();
|
||||
$table->string('attachable_type');
|
||||
$table->unsignedBigInteger('attachable_id');
|
||||
$table->uuid('key')->unique();
|
||||
$table->string('path');
|
||||
$table->string('filename');
|
||||
$table->string('type')->nullable();
|
||||
$table->string('type');
|
||||
$table->string('mime_type');
|
||||
$table->string('extension', 20)->nullable();
|
||||
$table->unsignedBigInteger('size')->default(0);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('attachments', function (Blueprint $table): void {
|
||||
$table->dropIndex(['attachable_type', 'attachable_id']);
|
||||
$table->dropColumn(['attachable_type', 'attachable_id']);
|
||||
});
|
||||
|
||||
Schema::create('attachable_attachments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('attachable_type');
|
||||
$table->unsignedBigInteger('attachable_id');
|
||||
$table->foreignId('attachment_id')->constrained('attachments')->cascadeOnDelete();
|
||||
|
||||
$table->unique(['attachable_type', 'attachable_id', 'attachment_id'], 'attachable_attachments_unique');
|
||||
$table->index(['attachable_type', 'attachable_id'], 'attachable_attachments_attachable_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('attachable_attachments');
|
||||
|
||||
Schema::table('attachments', function (Blueprint $table): void {
|
||||
$table->string('attachable_type');
|
||||
$table->unsignedBigInteger('attachable_id');
|
||||
$table->index(['attachable_type', 'attachable_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
95
postman/attachable-s3-testing.postman_collection.json
Normal file
95
postman/attachable-s3-testing.postman_collection.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "8faefdb8-734a-4262-a3b6-4d49e56ea901",
|
||||
"name": "Storage Test S3",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://127.0.0.1:8000"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "10"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "Upload Test File",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": []
|
||||
},
|
||||
{
|
||||
"key": "directory",
|
||||
"value": "testing/manual",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "{{expires_in_minutes}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/storage-test/s3/upload",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"storage-test",
|
||||
"s3",
|
||||
"upload"
|
||||
]
|
||||
},
|
||||
"description": "Sube un archivo al disco s3 y devuelve el path junto con una temporary_url."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Generate Temporary URL",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/storage-test/s3/temporary-url?path={{path}}&expires_in_minutes={{expires_in_minutes}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"storage-test",
|
||||
"s3",
|
||||
"temporary-url"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "path",
|
||||
"value": "{{path}}"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "{{expires_in_minutes}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Genera una URL temporal para un path ya existente en S3."
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,4 +9,5 @@ Route::get('/user', function (Request $request) {
|
||||
|
||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||
|
||||
171
tests/Feature/Attachable/AttachmentTest.php
Normal file
171
tests/Feature/Attachable/AttachmentTest.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
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 Illuminate\Support\Str;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AttachmentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
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',
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->image('logo.png');
|
||||
|
||||
$attachment = app(AttachmentService::class)->store(
|
||||
$file,
|
||||
'attachments/acme/logo.png',
|
||||
);
|
||||
$tenant->attachments()->attach($attachment->getKey());
|
||||
|
||||
$this->assertSame(AttachmentType::Image, $attachment->type);
|
||||
$this->assertTrue(Str::isUuid($attachment->key));
|
||||
$this->assertSame('logo.png', $attachment->filename);
|
||||
$this->assertSame('attachments/acme/'.$attachment->key, $attachment->path);
|
||||
Storage::disk('s3')->assertExists('attachments/acme/'.$attachment->key);
|
||||
$this->assertDatabaseHas('attachments', [
|
||||
'id' => $attachment->id,
|
||||
'key' => $attachment->key,
|
||||
'path' => 'attachments/acme/'.$attachment->key,
|
||||
'filename' => 'logo.png',
|
||||
'type' => AttachmentType::Image->value,
|
||||
]);
|
||||
$this->assertDatabaseHas('attachable_attachments', [
|
||||
'attachable_type' => $tenant->getMorphClass(),
|
||||
'attachable_id' => $tenant->getKey(),
|
||||
'attachment_id' => $attachment->id,
|
||||
]);
|
||||
|
||||
$freshAttachment = Attachment::query()->findOrFail($attachment->id);
|
||||
|
||||
$this->assertSame(AttachmentType::Image, $freshAttachment->type);
|
||||
$this->assertTrue($tenant->attachments->contains($freshAttachment));
|
||||
}
|
||||
|
||||
public function test_it_does_not_persist_the_attachment_when_the_s3_upload_fails(): void
|
||||
{
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'globex.com',
|
||||
]);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
Storage::shouldReceive('disk')
|
||||
->once()
|
||||
->with('s3')
|
||||
->andReturn($disk);
|
||||
$disk->shouldReceive('putFileAs')
|
||||
->once()
|
||||
->with('attachments/globex', Mockery::type(UploadedFile::class), Mockery::on(static fn (string $value): bool => Str::isUuid($value)))
|
||||
->andReturn(false);
|
||||
|
||||
try {
|
||||
app(AttachmentService::class)->store(
|
||||
UploadedFile::fake()->create('manual.pdf', 10, 'application/pdf'),
|
||||
'attachments/globex/manual.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 = Attachment::query()->create([
|
||||
'path' => 'attachments/initech/spec.pdf',
|
||||
'key' => (string) Str::uuid(),
|
||||
'filename' => 'spec.pdf',
|
||||
'type' => AttachmentType::Pdf,
|
||||
'mime_type' => 'application/pdf',
|
||||
'extension' => 'pdf',
|
||||
'size' => 512,
|
||||
]);
|
||||
$tenant->attachments()->attach($attachment->getKey());
|
||||
|
||||
app(AttachmentService::class)->delete($attachment);
|
||||
|
||||
Storage::disk('s3')->assertMissing('attachments/initech/spec.pdf');
|
||||
$this->assertDatabaseMissing('attachments', [
|
||||
'id' => $attachment->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('attachable_attachments', [
|
||||
'attachment_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 = Attachment::query()->create([
|
||||
'path' => 'attachments/umbrella/audio.mp3',
|
||||
'key' => (string) Str::uuid(),
|
||||
'filename' => 'audio.mp3',
|
||||
'type' => AttachmentType::Audio,
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'extension' => 'mp3',
|
||||
'size' => 1024,
|
||||
]);
|
||||
$tenant->attachments()->attach($attachment->getKey());
|
||||
|
||||
$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,
|
||||
]);
|
||||
$this->assertDatabaseHas('attachable_attachments', [
|
||||
'attachment_id' => $attachment->id,
|
||||
'attachable_type' => $tenant->getMorphClass(),
|
||||
'attachable_id' => $tenant->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user