diff --git a/app/Actions/Files/StoreUploadedFileAction.php b/app/Actions/Files/StoreUploadedFileAction.php new file mode 100644 index 0000000..10b0d82 --- /dev/null +++ b/app/Actions/Files/StoreUploadedFileAction.php @@ -0,0 +1,71 @@ +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 { + $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' => $size, + 'checksum' => $checksum, + 'metadata_json' => $metadata, + 'status' => FileStatus::Analyzed, + 'expires_at' => $this->expirationPolicy->forUploadedFile($user), + ]); + } catch (Throwable $e) { + $disk->delete($path); + + if ($e instanceof FileStorageException) { + throw $e; + } + + throw FileStorageException::cannotCreateRecord($file->getClientOriginalName(), previous: $e); + } + } +} 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 @@ + */ + use HasFactory; + + protected $table = 'files'; + + protected function casts(): array + { + return [ + 'metadata_json' => 'array', + 'status' => FileStatus::class, + 'expires_at' => 'datetime', + 'size_bytes' => 'integer', + ]; + } + + 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/app/Support/Files/FileExpirationPolicy.php b/app/Support/Files/FileExpirationPolicy.php new file mode 100644 index 0000000..5cf6a71 --- /dev/null +++ b/app/Support/Files/FileExpirationPolicy.php @@ -0,0 +1,25 @@ +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/FileFormatDetector.php b/app/Support/Files/FileFormatDetector.php new file mode 100644 index 0000000..7ba6fff --- /dev/null +++ b/app/Support/Files/FileFormatDetector.php @@ -0,0 +1,41 @@ + '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); + } +} 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/app/Support/Files/ImageMetadataExtractor.php b/app/Support/Files/ImageMetadataExtractor.php new file mode 100644 index 0000000..cbe33bd --- /dev/null +++ b/app/Support/Files/ImageMetadataExtractor.php @@ -0,0 +1,42 @@ +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], + 'supports_transparency' => $this->formatSupportsTransparency($format), + 'orientation' => null, + ]; + } + + private function formatSupportsTransparency(string $format): bool + { + return $format !== 'jpg'; + } +} 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/database/factories/FileRecordFactory.php b/database/factories/FileRecordFactory.php new file mode 100644 index 0000000..0f75af3 --- /dev/null +++ b/database/factories/FileRecordFactory.php @@ -0,0 +1,40 @@ + + */ +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' => FileStatus::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..7ca7528 --- /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')->unique(); + $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/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(); +}); 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]); +}); 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); +}); 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']); +}); 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); 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); +}); 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(); +}); 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); diff --git a/tests/Unit/Files/ImageMetadataExtractorTest.php b/tests/Unit/Files/ImageMetadataExtractorTest.php new file mode 100644 index 0000000..a1ebb55 --- /dev/null +++ b/tests/Unit/Files/ImageMetadataExtractorTest.php @@ -0,0 +1,34 @@ +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('supports_transparency'); + expect($metadata['supports_transparency'])->toBeTrue(); +}); + +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['supports_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(); +}); 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(); +});