From 671c70629796e8df0cf6996a3d996e0478310b41 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 15:50:59 +0300 Subject: [PATCH 01/14] CONV-058: Create FileRecord model and migration --- app/Models/FileRecord.php | 43 +++++++++++++++++++ database/factories/FileRecordFactory.php | 39 +++++++++++++++++ .../2026_05_29_154149_create_files_table.php | 36 ++++++++++++++++ tests/Feature/Files/FileRecordTest.php | 30 +++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 app/Models/FileRecord.php create mode 100644 database/factories/FileRecordFactory.php create mode 100644 database/migrations/2026_05_29_154149_create_files_table.php create mode 100644 tests/Feature/Files/FileRecordTest.php diff --git a/app/Models/FileRecord.php b/app/Models/FileRecord.php new file mode 100644 index 0000000..8c970ac --- /dev/null +++ b/app/Models/FileRecord.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $table = 'files'; + + protected function casts(): array + { + return [ + 'metadata_json' => 'array', + 'expires_at' => 'datetime', + 'size_bytes' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/factories/FileRecordFactory.php b/database/factories/FileRecordFactory.php new file mode 100644 index 0000000..9b11d95 --- /dev/null +++ b/database/factories/FileRecordFactory.php @@ -0,0 +1,39 @@ + + */ +class FileRecordFactory extends Factory +{ + protected $model = FileRecord::class; + + public function definition(): array + { + $extension = fake()->randomElement(['png', 'jpg', 'webp', 'pdf']); + $mime = match ($extension) { + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'webp' => 'image/webp', + 'pdf' => 'application/pdf', + }; + + return [ + 'user_id' => User::factory(), + 'original_name' => fake()->slug().'.'.$extension, + 'stored_path' => 'uploads/'.fake()->uuid().'.'.$extension, + 'mime_type' => $mime, + 'extension' => $extension, + 'size_bytes' => fake()->numberBetween(1000, 5_000_000), + 'checksum' => hash('sha256', fake()->uuid()), + 'metadata_json' => [], + 'status' => 'uploaded', + 'expires_at' => now()->addDay(), + ]; + } +} diff --git a/database/migrations/2026_05_29_154149_create_files_table.php b/database/migrations/2026_05_29_154149_create_files_table.php new file mode 100644 index 0000000..922aaba --- /dev/null +++ b/database/migrations/2026_05_29_154149_create_files_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('original_name'); + $table->string('stored_path'); + $table->string('mime_type')->nullable(); + $table->string('extension', 20); + $table->unsignedBigInteger('size_bytes'); + $table->string('checksum', 64)->nullable(); + $table->json('metadata_json')->nullable(); + $table->string('status', 30)->default('uploaded'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + $table->index(['extension']); + $table->index(['status']); + $table->index(['expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('files'); + } +}; diff --git a/tests/Feature/Files/FileRecordTest.php b/tests/Feature/Files/FileRecordTest.php new file mode 100644 index 0000000..4beaf41 --- /dev/null +++ b/tests/Feature/Files/FileRecordTest.php @@ -0,0 +1,30 @@ +create(); + + $file = FileRecord::factory()->create([ + 'user_id' => $user->id, + 'original_name' => 'image.png', + 'stored_path' => 'uploads/test.png', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size_bytes' => 12345, + 'metadata_json' => ['width' => 100, 'height' => 100], + ]); + + expect($file->exists)->toBeTrue(); + expect($file->user->is($user))->toBeTrue(); + expect($file->metadata_json)->toBeArray(); + expect($file->metadata_json['width'])->toBe(100); +}); + +it('casts expires_at to a datetime', function () { + $file = FileRecord::factory()->create(); + + expect($file->expires_at)->toBeInstanceOf(Carbon::class); +}); From 6a478ec037971a2a8ef32cd103248387a670b08a Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 15:59:19 +0300 Subject: [PATCH 02/14] CONV-059: Add FileStatus enum --- app/Enums/FileStatus.php | 12 ++++++++++++ app/Models/FileRecord.php | 2 ++ database/factories/FileRecordFactory.php | 3 ++- tests/Feature/Files/FileStatusEnumTest.php | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 app/Enums/FileStatus.php create mode 100644 tests/Feature/Files/FileStatusEnumTest.php diff --git a/app/Enums/FileStatus.php b/app/Enums/FileStatus.php new file mode 100644 index 0000000..e813ac2 --- /dev/null +++ b/app/Enums/FileStatus.php @@ -0,0 +1,12 @@ + 'array', + 'status' => FileStatus::class, 'expires_at' => 'datetime', 'size_bytes' => 'integer', ]; diff --git a/database/factories/FileRecordFactory.php b/database/factories/FileRecordFactory.php index 9b11d95..0f75af3 100644 --- a/database/factories/FileRecordFactory.php +++ b/database/factories/FileRecordFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\FileStatus; use App\Models\FileRecord; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -32,7 +33,7 @@ public function definition(): array 'size_bytes' => fake()->numberBetween(1000, 5_000_000), 'checksum' => hash('sha256', fake()->uuid()), 'metadata_json' => [], - 'status' => 'uploaded', + 'status' => FileStatus::Uploaded, 'expires_at' => now()->addDay(), ]; } diff --git a/tests/Feature/Files/FileStatusEnumTest.php b/tests/Feature/Files/FileStatusEnumTest.php new file mode 100644 index 0000000..d5b983a --- /dev/null +++ b/tests/Feature/Files/FileStatusEnumTest.php @@ -0,0 +1,18 @@ +create([ + 'status' => FileStatus::Analyzed, + ]); + + expect($file->fresh()->status)->toBe(FileStatus::Analyzed); +}); + +it('exposes all expected file statuses', function () { + $values = array_map(fn (FileStatus $s) => $s->value, FileStatus::cases()); + + expect($values)->toEqual(['uploaded', 'analyzed', 'failed', 'expired', 'deleted']); +}); From 111b59cbf02134c4c3ec00b172e3cfb41942b039 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 15:59:41 +0300 Subject: [PATCH 03/14] CONV-060: Test file format detection --- tests/Unit/Files/FileFormatDetectorTest.php | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/Unit/Files/FileFormatDetectorTest.php diff --git a/tests/Unit/Files/FileFormatDetectorTest.php b/tests/Unit/Files/FileFormatDetectorTest.php new file mode 100644 index 0000000..e60ffde --- /dev/null +++ b/tests/Unit/Files/FileFormatDetectorTest.php @@ -0,0 +1,41 @@ +image('test.png'); + + expect(app(FileFormatDetector::class)->detect($file))->toBe('png'); +}); + +it('detects jpg format', function () { + $file = UploadedFile::fake()->image('test.jpg'); + + expect(app(FileFormatDetector::class)->detect($file))->toBe('jpg'); +}); + +it('normalizes jpeg extension to jpg', function () { + $file = UploadedFile::fake()->image('test.jpeg'); + + expect(app(FileFormatDetector::class)->detect($file))->toBe('jpg'); +}); + +it('detects webp format by mime', function () { + $file = UploadedFile::fake()->create('test.webp', 10, 'image/webp'); + + expect(app(FileFormatDetector::class)->detect($file))->toBe('webp'); +}); + +it('detects pdf format by mime', function () { + $file = UploadedFile::fake()->create('test.pdf', 10, 'application/pdf'); + + expect(app(FileFormatDetector::class)->detect($file))->toBe('pdf'); +}); + +it('rejects unsupported file format', function () { + $file = UploadedFile::fake()->create('test.txt', 10, 'text/plain'); + + app(FileFormatDetector::class)->detect($file); +})->throws(UnsupportedFileFormatException::class); From 64fd2817d225c33c8e9e60f6fba652eac39eb12f Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 16:35:43 +0300 Subject: [PATCH 04/14] CONV-061: Implement FileFormatDetector --- .../Files/UnsupportedFileFormatException.php | 15 +++++++ app/Support/Files/FileFormatDetector.php | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 app/Exceptions/Files/UnsupportedFileFormatException.php create mode 100644 app/Support/Files/FileFormatDetector.php diff --git a/app/Exceptions/Files/UnsupportedFileFormatException.php b/app/Exceptions/Files/UnsupportedFileFormatException.php new file mode 100644 index 0000000..77ec8c9 --- /dev/null +++ b/app/Exceptions/Files/UnsupportedFileFormatException.php @@ -0,0 +1,15 @@ + 'png', + 'image/jpeg' => 'jpg', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + ]; + + private const EXTENSION_TO_FORMAT = [ + 'png' => 'png', + 'jpg' => 'jpg', + 'jpeg' => 'jpg', + 'webp' => 'webp', + 'pdf' => 'pdf', + ]; + + public function detect(UploadedFile $file): string + { + $mime = $file->getMimeType(); + + if ($mime !== null && isset(self::MIME_TO_FORMAT[$mime])) { + return self::MIME_TO_FORMAT[$mime]; + } + + $extension = strtolower((string) $file->getClientOriginalExtension()); + + if ($extension !== '' && isset(self::EXTENSION_TO_FORMAT[$extension])) { + return self::EXTENSION_TO_FORMAT[$extension]; + } + + throw UnsupportedFileFormatException::forFile($file->getClientOriginalName(), $mime); + } +} From b7122ec1517b37647ca912fc74e319c1b2b40f14 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 16:35:56 +0300 Subject: [PATCH 05/14] CONV-062: Test image metadata extraction --- .../Unit/Files/ImageMetadataExtractorTest.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/Unit/Files/ImageMetadataExtractorTest.php diff --git a/tests/Unit/Files/ImageMetadataExtractorTest.php b/tests/Unit/Files/ImageMetadataExtractorTest.php new file mode 100644 index 0000000..e5fb8a2 --- /dev/null +++ b/tests/Unit/Files/ImageMetadataExtractorTest.php @@ -0,0 +1,33 @@ +image('image.png', 1200, 800); + + $metadata = app(ImageMetadataExtractor::class)->extract($file, 'png'); + + expect($metadata['width'])->toBe(1200); + expect($metadata['height'])->toBe(800); + expect($metadata)->toHaveKey('has_transparency'); +}); + +it('extracts jpg dimensions and reports no transparency', function () { + $file = UploadedFile::fake()->image('image.jpg', 640, 480); + + $metadata = app(ImageMetadataExtractor::class)->extract($file, 'jpg'); + + expect($metadata['width'])->toBe(640); + expect($metadata['height'])->toBe(480); + expect($metadata['has_transparency'])->toBeFalse(); +}); + +it('returns empty metadata for pdf', function () { + $file = UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'); + + $metadata = app(ImageMetadataExtractor::class)->extract($file, 'pdf'); + + expect($metadata)->toBeArray(); + expect($metadata)->toBeEmpty(); +}); From b6546c4c0cb9e90c082618260989762e21c6dfbb Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 16:36:16 +0300 Subject: [PATCH 06/14] CONV-063: Implement ImageMetadataExtractor --- .../Files/FileMetadataException.php | 13 ++++++ app/Support/Files/ImageMetadataExtractor.php | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 app/Exceptions/Files/FileMetadataException.php create mode 100644 app/Support/Files/ImageMetadataExtractor.php diff --git a/app/Exceptions/Files/FileMetadataException.php b/app/Exceptions/Files/FileMetadataException.php new file mode 100644 index 0000000..4e8acaa --- /dev/null +++ b/app/Exceptions/Files/FileMetadataException.php @@ -0,0 +1,13 @@ +getRealPath(); + + if ($path === false) { + throw FileMetadataException::cannotReadImage($file->getClientOriginalName()); + } + + $size = @getimagesize($path); + + if ($size === false) { + throw FileMetadataException::cannotReadImage($file->getClientOriginalName()); + } + + return [ + 'width' => $size[0], + 'height' => $size[1], + 'has_transparency' => $this->hasTransparency($format), + 'orientation' => null, + ]; + } + + private function hasTransparency(string $format): bool + { + return $format !== 'jpg'; + } +} From a67034e0818ead1512487824967824b8abf6379c Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 16:36:30 +0300 Subject: [PATCH 07/14] CONV-064: Test uploaded file storage --- .../Files/StoreUploadedFileActionTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/Feature/Files/StoreUploadedFileActionTest.php diff --git a/tests/Feature/Files/StoreUploadedFileActionTest.php b/tests/Feature/Files/StoreUploadedFileActionTest.php new file mode 100644 index 0000000..4b3a341 --- /dev/null +++ b/tests/Feature/Files/StoreUploadedFileActionTest.php @@ -0,0 +1,30 @@ +create(); + $upload = UploadedFile::fake()->image('avatar.png', 1200, 800); + + $record = app(StoreUploadedFileAction::class)->handle($user, $upload); + + expect($record)->toBeInstanceOf(FileRecord::class); + expect($record->user_id)->toBe($user->id); + expect($record->original_name)->toBe('avatar.png'); + expect($record->extension)->toBe('png'); + expect($record->status)->toBe(FileStatus::Analyzed); + expect($record->metadata_json['width'])->toBe(1200); + expect($record->metadata_json['height'])->toBe(800); + expect($record->checksum)->not->toBeEmpty(); + expect($record->size_bytes)->toBeGreaterThan(0); + expect($record->expires_at)->not->toBeNull(); + + Storage::disk('local')->assertExists($record->stored_path); +}); From 36523d028716cfa74ff63894b2182b08ca82dbc5 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 16:36:53 +0300 Subject: [PATCH 08/14] CONV-065: Implement StoreUploadedFileAction --- app/Actions/Files/StoreUploadedFileAction.php | 53 +++++++++++++++++++ app/Exceptions/Files/FileStorageException.php | 19 +++++++ 2 files changed, 72 insertions(+) create mode 100644 app/Actions/Files/StoreUploadedFileAction.php create mode 100644 app/Exceptions/Files/FileStorageException.php diff --git a/app/Actions/Files/StoreUploadedFileAction.php b/app/Actions/Files/StoreUploadedFileAction.php new file mode 100644 index 0000000..65c8d86 --- /dev/null +++ b/app/Actions/Files/StoreUploadedFileAction.php @@ -0,0 +1,53 @@ +formatDetector->detect($file); + $metadata = $this->metadataExtractor->extract($file, $format); + + $disk = Storage::disk('local'); + $path = $file->store("uploads/{$user->id}", 'local'); + + if (! is_string($path) || $path === '') { + throw FileStorageException::cannotStore($file->getClientOriginalName()); + } + + try { + return FileRecord::query()->create([ + 'user_id' => $user->id, + 'original_name' => $file->getClientOriginalName(), + 'stored_path' => $path, + 'mime_type' => $file->getMimeType(), + 'extension' => $format, + 'size_bytes' => $disk->size($path), + 'checksum' => hash_file('sha256', $disk->path($path)), + 'metadata_json' => $metadata, + 'status' => FileStatus::Analyzed, + 'expires_at' => now()->addDay(), + ]); + } catch (Throwable $e) { + $disk->delete($path); + + throw FileStorageException::cannotCreateRecord($file->getClientOriginalName(), previous: $e); + } + } +} diff --git a/app/Exceptions/Files/FileStorageException.php b/app/Exceptions/Files/FileStorageException.php new file mode 100644 index 0000000..455c1b8 --- /dev/null +++ b/app/Exceptions/Files/FileStorageException.php @@ -0,0 +1,19 @@ + Date: Fri, 29 May 2026 16:51:25 +0300 Subject: [PATCH 09/14] CONV-066: Add uploaded file validation rules --- app/Support/Files/UploadedFileRules.php | 21 ++++++++++++++ tests/Unit/Files/UploadedFileRulesTest.php | 33 ++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 app/Support/Files/UploadedFileRules.php create mode 100644 tests/Unit/Files/UploadedFileRulesTest.php diff --git a/app/Support/Files/UploadedFileRules.php b/app/Support/Files/UploadedFileRules.php new file mode 100644 index 0000000..086cf37 --- /dev/null +++ b/app/Support/Files/UploadedFileRules.php @@ -0,0 +1,21 @@ + + */ + public static function rules(): array + { + return [ + 'required', + 'file', + 'max:'.self::MAX_FILE_KILOBYTES, + 'mimes:png,jpg,jpeg,webp,pdf', + ]; + } +} diff --git a/tests/Unit/Files/UploadedFileRulesTest.php b/tests/Unit/Files/UploadedFileRulesTest.php new file mode 100644 index 0000000..70bd575 --- /dev/null +++ b/tests/Unit/Files/UploadedFileRulesTest.php @@ -0,0 +1,33 @@ +toContain('file'); + expect($rules)->toContain('required'); + expect($joined)->toContain('mimes:png,jpg,jpeg,webp,pdf'); + expect($joined)->toContain('max:'); +}); + +it('accepts a valid png upload', function () { + $validator = Validator::make( + ['file' => UploadedFile::fake()->image('image.png')], + ['file' => UploadedFileRules::rules()], + ); + + expect($validator->passes())->toBeTrue(); +}); + +it('rejects an unsupported uploaded file', function () { + $validator = Validator::make( + ['file' => UploadedFile::fake()->create('note.txt', 1, 'text/plain')], + ['file' => UploadedFileRules::rules()], + ); + + expect($validator->fails())->toBeTrue(); +}); From 78fbd0325cd034fc4e9d6e197a207facc7d5ae17 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 17:19:36 +0300 Subject: [PATCH 10/14] CONV-067: Add file ownership rules --- app/Models/FileRecord.php | 6 +++++ tests/Feature/Files/FileOwnershipTest.php | 33 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/Feature/Files/FileOwnershipTest.php diff --git a/app/Models/FileRecord.php b/app/Models/FileRecord.php index 2f68a64..75908c4 100644 --- a/app/Models/FileRecord.php +++ b/app/Models/FileRecord.php @@ -5,6 +5,7 @@ use App\Enums\FileStatus; use Database\Factories\FileRecordFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -42,4 +43,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function scopeForUser(Builder $query, User $user): Builder + { + return $query->where('user_id', $user->id); + } } diff --git a/tests/Feature/Files/FileOwnershipTest.php b/tests/Feature/Files/FileOwnershipTest.php new file mode 100644 index 0000000..2b9ae60 --- /dev/null +++ b/tests/Feature/Files/FileOwnershipTest.php @@ -0,0 +1,33 @@ +create(); + $other = User::factory()->create(); + + $record = app(StoreUploadedFileAction::class)->handle( + $user, + UploadedFile::fake()->image('image.png'), + ); + + expect($record->user_id)->toBe($user->id); + expect($record->user_id)->not->toBe($other->id); +}); + +it('scopes file records to owner', function () { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $own = FileRecord::factory()->for($user)->create(); + FileRecord::factory()->for($other)->create(); + + expect(FileRecord::query()->forUser($user)->pluck('id')->all()) + ->toBe([$own->id]); +}); From b3e40baa56a051248d920a08d6c480a2a378da84 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 17:33:06 +0300 Subject: [PATCH 11/14] CONV-068: Add file expiration defaults --- app/Actions/Files/StoreUploadedFileAction.php | 4 +++- app/Support/Files/FileExpirationPolicy.php | 20 ++++++++++++++++ .../Files/FileExpirationPolicyTest.php | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 app/Support/Files/FileExpirationPolicy.php create mode 100644 tests/Feature/Files/FileExpirationPolicyTest.php diff --git a/app/Actions/Files/StoreUploadedFileAction.php b/app/Actions/Files/StoreUploadedFileAction.php index 65c8d86..4a5685e 100644 --- a/app/Actions/Files/StoreUploadedFileAction.php +++ b/app/Actions/Files/StoreUploadedFileAction.php @@ -6,6 +6,7 @@ use App\Exceptions\Files\FileStorageException; use App\Models\FileRecord; use App\Models\User; +use App\Support\Files\FileExpirationPolicy; use App\Support\Files\FileFormatDetector; use App\Support\Files\ImageMetadataExtractor; use Illuminate\Http\UploadedFile; @@ -17,6 +18,7 @@ final class StoreUploadedFileAction public function __construct( private readonly FileFormatDetector $formatDetector, private readonly ImageMetadataExtractor $metadataExtractor, + private readonly FileExpirationPolicy $expirationPolicy, ) {} public function handle(User $user, UploadedFile $file): FileRecord @@ -42,7 +44,7 @@ public function handle(User $user, UploadedFile $file): FileRecord 'checksum' => hash_file('sha256', $disk->path($path)), 'metadata_json' => $metadata, 'status' => FileStatus::Analyzed, - 'expires_at' => now()->addDay(), + 'expires_at' => $this->expirationPolicy->forUploadedFile($user), ]); } catch (Throwable $e) { $disk->delete($path); diff --git a/app/Support/Files/FileExpirationPolicy.php b/app/Support/Files/FileExpirationPolicy.php new file mode 100644 index 0000000..cde07e1 --- /dev/null +++ b/app/Support/Files/FileExpirationPolicy.php @@ -0,0 +1,20 @@ +addDay(); + } + + public function forResultFile(User $user): CarbonInterface + { + return Date::now()->addDay(); + } +} diff --git a/tests/Feature/Files/FileExpirationPolicyTest.php b/tests/Feature/Files/FileExpirationPolicyTest.php new file mode 100644 index 0000000..80faa09 --- /dev/null +++ b/tests/Feature/Files/FileExpirationPolicyTest.php @@ -0,0 +1,23 @@ +create(); + + $expiresAt = app(FileExpirationPolicy::class)->forUploadedFile($user); + + expect($expiresAt)->toBeInstanceOf(CarbonInterface::class); + expect($expiresAt->greaterThan(now()))->toBeTrue(); +}); + +it('returns a future result file expiration', function () { + $user = User::factory()->create(); + + $expiresAt = app(FileExpirationPolicy::class)->forResultFile($user); + + expect($expiresAt)->toBeInstanceOf(CarbonInterface::class); + expect($expiresAt->greaterThan(now()))->toBeTrue(); +}); From e04ee8a228fbc6d7c66e4dac00b12bcfa747394e Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 17:38:47 +0300 Subject: [PATCH 12/14] CONV-069: Add stored file cleanup safety test --- app/Actions/Files/StoreUploadedFileAction.php | 4 +- app/Support/Files/FileRecordCreator.php | 16 ++++++++ .../Files/StoreUploadedFileCleanupTest.php | 40 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 app/Support/Files/FileRecordCreator.php create mode 100644 tests/Feature/Files/StoreUploadedFileCleanupTest.php diff --git a/app/Actions/Files/StoreUploadedFileAction.php b/app/Actions/Files/StoreUploadedFileAction.php index 4a5685e..d236ed8 100644 --- a/app/Actions/Files/StoreUploadedFileAction.php +++ b/app/Actions/Files/StoreUploadedFileAction.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Support\Files\FileExpirationPolicy; use App\Support\Files\FileFormatDetector; +use App\Support\Files\FileRecordCreator; use App\Support\Files\ImageMetadataExtractor; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; @@ -19,6 +20,7 @@ public function __construct( private readonly FileFormatDetector $formatDetector, private readonly ImageMetadataExtractor $metadataExtractor, private readonly FileExpirationPolicy $expirationPolicy, + private readonly FileRecordCreator $recordCreator, ) {} public function handle(User $user, UploadedFile $file): FileRecord @@ -34,7 +36,7 @@ public function handle(User $user, UploadedFile $file): FileRecord } try { - return FileRecord::query()->create([ + return $this->recordCreator->create([ 'user_id' => $user->id, 'original_name' => $file->getClientOriginalName(), 'stored_path' => $path, diff --git a/app/Support/Files/FileRecordCreator.php b/app/Support/Files/FileRecordCreator.php new file mode 100644 index 0000000..3be99b3 --- /dev/null +++ b/app/Support/Files/FileRecordCreator.php @@ -0,0 +1,16 @@ + $attributes + */ + public function create(array $attributes): FileRecord + { + return FileRecord::query()->create($attributes); + } +} diff --git a/tests/Feature/Files/StoreUploadedFileCleanupTest.php b/tests/Feature/Files/StoreUploadedFileCleanupTest.php new file mode 100644 index 0000000..28afeed --- /dev/null +++ b/tests/Feature/Files/StoreUploadedFileCleanupTest.php @@ -0,0 +1,40 @@ +app->bind(FileRecordCreator::class, function () { + return new class extends FileRecordCreator + { + public function create(array $attributes): FileRecord + { + throw new RuntimeException('simulated db failure'); + } + }; + }); + + $user = User::factory()->create(); + + try { + app(StoreUploadedFileAction::class)->handle( + $user, + UploadedFile::fake()->image('image.png'), + ); + $this->fail('Expected FileStorageException to be thrown.'); + } catch (FileStorageException $e) { + // expected + } + + expect(FileRecord::query()->count())->toBe(0); + + $storedFiles = Storage::disk('local')->allFiles("uploads/{$user->id}"); + expect($storedFiles)->toBeEmpty(); +}); From c4da2520111a486aad0a20a9a13d9acfeb11c89f Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 17:39:23 +0300 Subject: [PATCH 13/14] CONV-070: Add file storage smoke tests --- tests/Feature/Files/FileStorageSmokeTest.php | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/Feature/Files/FileStorageSmokeTest.php diff --git a/tests/Feature/Files/FileStorageSmokeTest.php b/tests/Feature/Files/FileStorageSmokeTest.php new file mode 100644 index 0000000..c91e8a8 --- /dev/null +++ b/tests/Feature/Files/FileStorageSmokeTest.php @@ -0,0 +1,56 @@ +create(); + $upload = UploadedFile::fake()->image($filename, 800, 600); + + $record = app(StoreUploadedFileAction::class)->handle($user, $upload); + + expect($record->extension)->toBe($expectedFormat); + expect($record->status)->toBe(FileStatus::Analyzed); + expect($record->metadata_json['width'])->toBe(800); + expect($record->metadata_json['height'])->toBe(600); + + Storage::disk('local')->assertExists($record->stored_path); +})->with([ + ['image.png', 'png'], + ['image.jpg', 'jpg'], + ['image.jpeg', 'jpg'], +]); + +it('stores pdf upload with empty metadata', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $upload = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + $record = app(StoreUploadedFileAction::class)->handle($user, $upload); + + expect($record->extension)->toBe('pdf'); + expect($record->status)->toBe(FileStatus::Analyzed); + expect($record->metadata_json)->toBeArray(); + expect($record->metadata_json)->toBeEmpty(); + expect($record->checksum)->not->toBeEmpty(); + + Storage::disk('local')->assertExists($record->stored_path); +}); + +it('rejects an unsupported file upload in the storage pipeline', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + + app(StoreUploadedFileAction::class)->handle( + $user, + UploadedFile::fake()->create('note.txt', 10, 'text/plain'), + ); +})->throws(UnsupportedFileFormatException::class); From 272a3745382d790aa45cd52e6bb6085070c3a536 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Fri, 29 May 2026 18:00:42 +0300 Subject: [PATCH 14/14] review: address Phase 05 PR feedback - StoreUploadedFileAction: validate disk size and hash_file results before persisting record - ImageMetadataExtractor: rename hasTransparency -> formatSupportsTransparency and key supports_transparency to match capability semantics - FileExpirationPolicy: extract shared calculateExpiration() helper - files migration: add unique index on stored_path --- app/Actions/Files/StoreUploadedFileAction.php | 18 ++++++++++++++++-- app/Support/Files/FileExpirationPolicy.php | 7 ++++++- app/Support/Files/ImageMetadataExtractor.php | 4 ++-- .../2026_05_29_154149_create_files_table.php | 2 +- .../Unit/Files/ImageMetadataExtractorTest.php | 5 +++-- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/Actions/Files/StoreUploadedFileAction.php b/app/Actions/Files/StoreUploadedFileAction.php index d236ed8..10b0d82 100644 --- a/app/Actions/Files/StoreUploadedFileAction.php +++ b/app/Actions/Files/StoreUploadedFileAction.php @@ -36,14 +36,24 @@ public function handle(User $user, UploadedFile $file): FileRecord } try { + $size = $disk->size($path); + $absolutePath = $disk->path($path); + $checksum = is_string($absolutePath) && $absolutePath !== '' + ? hash_file('sha256', $absolutePath) + : false; + + if ($size === false || $checksum === false) { + throw FileStorageException::cannotStore($file->getClientOriginalName()); + } + return $this->recordCreator->create([ 'user_id' => $user->id, 'original_name' => $file->getClientOriginalName(), 'stored_path' => $path, 'mime_type' => $file->getMimeType(), 'extension' => $format, - 'size_bytes' => $disk->size($path), - 'checksum' => hash_file('sha256', $disk->path($path)), + 'size_bytes' => $size, + 'checksum' => $checksum, 'metadata_json' => $metadata, 'status' => FileStatus::Analyzed, 'expires_at' => $this->expirationPolicy->forUploadedFile($user), @@ -51,6 +61,10 @@ public function handle(User $user, UploadedFile $file): FileRecord } catch (Throwable $e) { $disk->delete($path); + if ($e instanceof FileStorageException) { + throw $e; + } + throw FileStorageException::cannotCreateRecord($file->getClientOriginalName(), previous: $e); } } diff --git a/app/Support/Files/FileExpirationPolicy.php b/app/Support/Files/FileExpirationPolicy.php index cde07e1..5cf6a71 100644 --- a/app/Support/Files/FileExpirationPolicy.php +++ b/app/Support/Files/FileExpirationPolicy.php @@ -10,10 +10,15 @@ final class FileExpirationPolicy { public function forUploadedFile(User $user): CarbonInterface { - return Date::now()->addDay(); + return $this->calculateExpiration($user); } public function forResultFile(User $user): CarbonInterface + { + return $this->calculateExpiration($user); + } + + private function calculateExpiration(User $user): CarbonInterface { return Date::now()->addDay(); } diff --git a/app/Support/Files/ImageMetadataExtractor.php b/app/Support/Files/ImageMetadataExtractor.php index 0585ffd..cbe33bd 100644 --- a/app/Support/Files/ImageMetadataExtractor.php +++ b/app/Support/Files/ImageMetadataExtractor.php @@ -30,12 +30,12 @@ public function extract(UploadedFile $file, string $format): array return [ 'width' => $size[0], 'height' => $size[1], - 'has_transparency' => $this->hasTransparency($format), + 'supports_transparency' => $this->formatSupportsTransparency($format), 'orientation' => null, ]; } - private function hasTransparency(string $format): bool + private function formatSupportsTransparency(string $format): bool { return $format !== 'jpg'; } diff --git a/database/migrations/2026_05_29_154149_create_files_table.php b/database/migrations/2026_05_29_154149_create_files_table.php index 922aaba..7ca7528 100644 --- a/database/migrations/2026_05_29_154149_create_files_table.php +++ b/database/migrations/2026_05_29_154149_create_files_table.php @@ -12,7 +12,7 @@ public function up(): void $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('original_name'); - $table->string('stored_path'); + $table->string('stored_path')->unique(); $table->string('mime_type')->nullable(); $table->string('extension', 20); $table->unsignedBigInteger('size_bytes'); diff --git a/tests/Unit/Files/ImageMetadataExtractorTest.php b/tests/Unit/Files/ImageMetadataExtractorTest.php index e5fb8a2..a1ebb55 100644 --- a/tests/Unit/Files/ImageMetadataExtractorTest.php +++ b/tests/Unit/Files/ImageMetadataExtractorTest.php @@ -10,7 +10,8 @@ expect($metadata['width'])->toBe(1200); expect($metadata['height'])->toBe(800); - expect($metadata)->toHaveKey('has_transparency'); + expect($metadata)->toHaveKey('supports_transparency'); + expect($metadata['supports_transparency'])->toBeTrue(); }); it('extracts jpg dimensions and reports no transparency', function () { @@ -20,7 +21,7 @@ expect($metadata['width'])->toBe(640); expect($metadata['height'])->toBe(480); - expect($metadata['has_transparency'])->toBeFalse(); + expect($metadata['supports_transparency'])->toBeFalse(); }); it('returns empty metadata for pdf', function () {