From 245de3852139f851647be1df8952556fb11cf3a8 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 16:35:21 +0300 Subject: [PATCH] CONV-302: Add API file upload endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Api/V1/FileController.php | 47 ++++++++++++++++ .../Requests/Api/V1/UploadFileRequest.php | 17 ++++++ app/Http/Resources/Api/V1/FileResource.php | 26 +++++++++ .../Resources/Api/V1/TargetFormatResource.php | 23 ++++++++ routes/api.php | 3 + .../Feature/Api/V1/FileUploadEndpointTest.php | 55 +++++++++++++++++++ 6 files changed, 171 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/FileController.php create mode 100644 app/Http/Requests/Api/V1/UploadFileRequest.php create mode 100644 app/Http/Resources/Api/V1/FileResource.php create mode 100644 app/Http/Resources/Api/V1/TargetFormatResource.php create mode 100644 tests/Feature/Api/V1/FileUploadEndpointTest.php diff --git a/app/Http/Controllers/Api/V1/FileController.php b/app/Http/Controllers/Api/V1/FileController.php new file mode 100644 index 0000000..4ffc770 --- /dev/null +++ b/app/Http/Controllers/Api/V1/FileController.php @@ -0,0 +1,47 @@ +handle( + user: $request->user(), + file: $request->file('file'), + ); + + return (new FileResource($fileRecord)) + ->response() + ->setStatusCode(201); + } + + public function targets(Request $request, FileRecord $file): JsonResource + { + abort_if( + $file->user_id !== $request->user()->id, + 403, + 'You do not own this file.', + ); + + $targets = $this->registry->targetsFor($file->extension); + + return TargetFormatResource::collection($targets); + } +} diff --git a/app/Http/Requests/Api/V1/UploadFileRequest.php b/app/Http/Requests/Api/V1/UploadFileRequest.php new file mode 100644 index 0000000..54ffd05 --- /dev/null +++ b/app/Http/Requests/Api/V1/UploadFileRequest.php @@ -0,0 +1,17 @@ + ['required', 'file'], + ]; + } +} diff --git a/app/Http/Resources/Api/V1/FileResource.php b/app/Http/Resources/Api/V1/FileResource.php new file mode 100644 index 0000000..98a135c --- /dev/null +++ b/app/Http/Resources/Api/V1/FileResource.php @@ -0,0 +1,26 @@ + $this->id, + 'original_name' => $this->original_name, + 'extension' => $this->extension, + 'mime_type' => $this->mime_type, + 'size_bytes' => $this->size_bytes, + 'created_at' => $this->created_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/V1/TargetFormatResource.php b/app/Http/Resources/Api/V1/TargetFormatResource.php new file mode 100644 index 0000000..6d3c0d9 --- /dev/null +++ b/app/Http/Resources/Api/V1/TargetFormatResource.php @@ -0,0 +1,23 @@ + $this->format, + 'label' => $this->label, + 'description' => $this->description, + 'recommended' => $this->recommended, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 3bc5d62..4c4714b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ name('converters.index'); Route::get('/converters/{source}/{target}/schema', [ConverterController::class, 'schema'])->name('converters.schema'); + + Route::post('/files', [FileController::class, 'store'])->name('files.store'); }); }); diff --git a/tests/Feature/Api/V1/FileUploadEndpointTest.php b/tests/Feature/Api/V1/FileUploadEndpointTest.php new file mode 100644 index 0000000..0246143 --- /dev/null +++ b/tests/Feature/Api/V1/FileUploadEndpointTest.php @@ -0,0 +1,55 @@ +create(['plan' => Plan::Pro]); + $token = app(ApiKeyGenerator::class)->create($user, 'API')->plainToken; + + $file = UploadedFile::fake()->image('image.png', 100, 100); + + $this->withToken($token) + ->postJson('/api/v1/files', ['file' => $file]) + ->assertCreated() + ->assertJsonPath('data.original_name', 'image.png') + ->assertJsonPath('data.extension', 'png') + ->assertJsonStructure(['data' => ['id', 'original_name', 'extension', 'size_bytes', 'created_at']]); + + $this->assertDatabaseHas('files', [ + 'user_id' => $user->id, + 'original_name' => 'image.png', + 'extension' => 'png', + ]); +}); + +it('requires file field to upload', function () { + $user = User::factory()->create(['plan' => Plan::Pro]); + $token = app(ApiKeyGenerator::class)->create($user, 'API')->plainToken; + + $this->withToken($token) + ->postJson('/api/v1/files', []) + ->assertUnprocessable(); +}); + +it('requires api key authentication to upload file', function () { + $this->postJson('/api/v1/files', []) + ->assertUnauthorized(); +}); + +it('rejects free user from file upload endpoint', function () { + $user = User::factory()->create(['plan' => Plan::Free]); + $token = app(ApiKeyGenerator::class)->create($user, 'Free')->plainToken; + + $this->withToken($token) + ->postJson('/api/v1/files', []) + ->assertForbidden() + ->assertJsonPath('error.code', 'api_not_available'); +});