From 06106a4b4fa97bde1b725793d8311cf26ca6d51f Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:00:33 +0300 Subject: [PATCH 01/18] CONV-147: Add convert button test Co-Authored-By: Claude Sonnet 4.6 --- .../DashboardConverterConvertTest.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/Feature/Livewire/DashboardConverterConvertTest.php diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php new file mode 100644 index 0000000..f355062 --- /dev/null +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -0,0 +1,33 @@ +create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('upload', UploadedFile::fake()->image('avatar.png', 800, 600)) + ->assertSet('step', 'format') + ->call('selectTargetFormat', 'jpg') + ->assertSet('step', 'settings') + ->set('options.quality', 'high') + ->call('continueFromSettings') + ->assertSet('step', 'convert') + ->call('convert') + ->assertSet('step', 'converting'); + + expect(ConversionJob::query()->count())->toBe(1); +}); From 4dfed2782743470170b15c2fecdecf7e03ed2e58 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:05:55 +0300 Subject: [PATCH 02/18] CONV-148: Connect convert button to CreateConversionJobAction Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Dashboard/DashboardConverter.php | 28 +++++++++++++++++-- .../DashboardConverterConvertTest.php | 1 - 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index ac97713..84b3915 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -2,6 +2,7 @@ namespace App\Livewire\Dashboard; +use App\Actions\Conversions\CreateConversionJobAction; use App\Actions\Files\StoreUploadedFileAction; use App\Exceptions\Files\FileStorageException; use App\Exceptions\Files\UnsupportedFileFormatException; @@ -48,6 +49,8 @@ class DashboardConverter extends Component */ public array $optionsByTarget = []; + public ?int $currentConversionJobId = null; + public function updatedUpload(): void { $this->storeUpload(app(StoreUploadedFileAction::class)); @@ -196,11 +199,32 @@ public function continueFromSettings(): void return; } - // Phase 8 stops at a convert placeholder. CreateConversionJobAction and - // the real conversion flow arrive in Phase 9. $this->step = 'convert'; } + public function convert(): void + { + if ($this->step === 'converting' || $this->currentConversionJobId !== null) { + return; + } + + $file = $this->currentFile; + + if ($file === null || $this->selectedTargetFormat === null) { + return; + } + + $job = app(CreateConversionJobAction::class)->handle( + user: auth()->user(), + sourceFile: $file, + targetFormat: $this->selectedTargetFormat, + options: $this->options, + ); + + $this->currentConversionJobId = $job->id; + $this->step = 'converting'; + } + public function validateSettings(): bool { $this->resetErrorBag(); diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index f355062..f8380c3 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -4,7 +4,6 @@ use App\Livewire\Dashboard\DashboardConverter; use App\Models\ConversionJob; -use App\Models\FileRecord; use App\Models\User; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Queue; From a2678a7eaf39010b6a62af63dec44218a677a926 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:06:50 +0300 Subject: [PATCH 03/18] CONV-149: Add duplicate convert protection Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/dashboard-converter.blade.php | 12 +++++++++++- .../DashboardConverterConvertTest.php | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index b60b2b3..13661ec 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -191,8 +191,18 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor

Ready to convert {{ strtoupper($file->extension) }} to {{ strtoupper($selectedTargetFormat) }}

-

Conversion will be available in Phase 9.

+ + + Convert Now + Starting… + @endif diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index f8380c3..4856bda 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -30,3 +30,22 @@ expect(ConversionJob::query()->count())->toBe(1); }); + +it('does not create duplicate conversion jobs on repeated convert calls', function () { + Queue::fake(); + Storage::fake('local'); + + $user = User::factory()->create(); + + $component = Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('upload', UploadedFile::fake()->image('avatar.png', 800, 600)) + ->call('selectTargetFormat', 'jpg') + ->set('options.quality', 'high') + ->call('continueFromSettings'); + + $component->call('convert'); + $component->call('convert'); + + expect(ConversionJob::query()->count())->toBe(1); +}); From f1393c7c8ebe81d67a7afac78dcc375b4de123b5 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:07:33 +0300 Subject: [PATCH 04/18] CONV-150: Add converting state test Co-Authored-By: Claude Sonnet 4.6 --- database/factories/ConversionJobFactory.php | 34 +++++++++++++++++++ .../DashboardConverterConvertTest.php | 16 +++++++++ 2 files changed, 50 insertions(+) diff --git a/database/factories/ConversionJobFactory.php b/database/factories/ConversionJobFactory.php index e8e2569..36120d0 100644 --- a/database/factories/ConversionJobFactory.php +++ b/database/factories/ConversionJobFactory.php @@ -36,4 +36,38 @@ public function definition(): array 'error_message' => null, ]; } + + public function queued(): static + { + return $this->state(['status' => ConversionStatus::Queued, 'progress' => 0]); + } + + public function processing(): static + { + return $this->state([ + 'status' => ConversionStatus::Processing, + 'progress' => 10, + 'started_at' => now(), + ]); + } + + public function completed(): static + { + return $this->state([ + 'status' => ConversionStatus::Completed, + 'progress' => 100, + 'started_at' => now()->subSeconds(5), + 'completed_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state([ + 'status' => ConversionStatus::Failed, + 'error_code' => 'driver_failed', + 'error_message' => 'Conversion driver encountered an error.', + 'completed_at' => now(), + ]); + } } diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index 4856bda..df0ab1b 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -31,6 +31,22 @@ expect(ConversionJob::query()->count())->toBe(1); }); +it('renders converting state while conversion is processing', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->processing()->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'converting') + ->set('currentConversionJobId', $job->id) + ->assertSee('Converting') + ->assertSee('PNG') + ->assertSee('JPG'); +}); + it('does not create duplicate conversion jobs on repeated convert calls', function () { Queue::fake(); Storage::fake('local'); From 959c322b51c7b8a48e216db30fa85442c6d1df94 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:47:23 +0300 Subject: [PATCH 05/18] CONV-151: Implement converting state UI Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Dashboard/DashboardConverter.php | 39 +++++++++++++++++++ .../dashboard/dashboard-converter.blade.php | 19 +++++++++ 2 files changed, 58 insertions(+) diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index 84b3915..f1f970c 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -4,8 +4,10 @@ use App\Actions\Conversions\CreateConversionJobAction; use App\Actions\Files\StoreUploadedFileAction; +use App\Enums\ConversionStatus; use App\Exceptions\Files\FileStorageException; use App\Exceptions\Files\UnsupportedFileFormatException; +use App\Models\ConversionJob; use App\Models\FileRecord; use App\Support\Converters\ConverterRegistry; use App\Support\Converters\DTO\ConverterTarget; @@ -309,6 +311,43 @@ private function resetTargetSelection(): void $this->optionsByTarget = []; } + public function getCurrentJobProperty(): ?ConversionJob + { + if ($this->currentConversionJobId === null) { + return null; + } + + return ConversionJob::query() + ->with(['sourceFile', 'resultFile']) + ->where('user_id', auth()->id()) + ->find($this->currentConversionJobId); + } + + public function refreshConversionStatus(): void + { + if ($this->currentConversionJobId === null) { + return; + } + + $job = ConversionJob::query() + ->where('user_id', auth()->id()) + ->find($this->currentConversionJobId); + + if (! $job) { + return; + } + + if ($job->status === ConversionStatus::Completed) { + $this->step = 'completed'; + + return; + } + + if ($job->status === ConversionStatus::Failed) { + $this->step = 'failed'; + } + } + public function getCurrentFileProperty(): ?FileRecord { return $this->currentFileId diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index 13661ec..2f40946 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -180,6 +180,25 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor @endif + @if ($step === 'converting') +
+
+
+ +
+

Converting your file

+ @if ($this->currentJob) +

+ {{ strtoupper($this->currentJob->source_format) }} → {{ strtoupper($this->currentJob->target_format) }} +

+ @endif +
+ +

Please keep this page open while we prepare your file.

+
+
+ @endif + @if ($step === 'convert' && $this->currentFile) @php($file = $this->currentFile)
From 36f370cf66318e26cfacc17f143f08d090994cbb Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:48:02 +0300 Subject: [PATCH 06/18] CONV-152: Add job status polling test Co-Authored-By: Claude Sonnet 4.6 --- .../DashboardConverterConvertTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index df0ab1b..cffb20a 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -47,6 +47,40 @@ ->assertSee('JPG'); }); +it('moves to completed step when current job is completed', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->completed()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'converting') + ->set('currentConversionJobId', $job->id) + ->call('refreshConversionStatus') + ->assertSet('step', 'completed'); +}); + +it('moves to failed step when current job is failed', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->failed()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'converting') + ->set('currentConversionJobId', $job->id) + ->call('refreshConversionStatus') + ->assertSet('step', 'failed'); +}); + +it('does nothing on refreshConversionStatus when no job exists', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'converting') + ->call('refreshConversionStatus') + ->assertSet('step', 'converting'); +}); + it('does not create duplicate conversion jobs on repeated convert calls', function () { Queue::fake(); Storage::fake('local'); From 20b309a963e6fedda88befda7afb1febc31dde6a Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:48:17 +0300 Subject: [PATCH 07/18] CONV-153: Implement job status polling refreshConversionStatus and wire:poll.2s implemented in CONV-151. Co-Authored-By: Claude Sonnet 4.6 From 0ee44c6ed6b8cbccbe6e518912f2e80ea6ae8ac1 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:48:53 +0300 Subject: [PATCH 08/18] CONV-154: Add completed state test Co-Authored-By: Claude Sonnet 4.6 --- .../DashboardConverterConvertTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index cffb20a..7856629 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -4,6 +4,7 @@ use App\Livewire\Dashboard\DashboardConverter; use App\Models\ConversionJob; +use App\Models\FileRecord; use App\Models\User; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Queue; @@ -47,6 +48,26 @@ ->assertSee('JPG'); }); +it('renders completed state with download action', function () { + $user = User::factory()->create(); + $resultFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'avatar.jpg', + 'extension' => 'jpg', + ]); + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'target_format' => 'jpg', + 'result_file_id' => $resultFile->id, + ]); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'completed') + ->set('currentConversionJobId', $job->id) + ->assertSee('Done') + ->assertSee('avatar.jpg') + ->assertSee('Download'); +}); + it('moves to completed step when current job is completed', function () { $user = User::factory()->create(); $job = ConversionJob::factory()->for($user)->completed()->create(); From b13154dd548187d7bb1771198036ebdae57dda3a Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:50:12 +0300 Subject: [PATCH 09/18] CONV-155: Implement completed state UI Co-Authored-By: Claude Sonnet 4.6 --- .../DownloadConversionResultController.php | 34 +++++++++++++++++++ app/Livewire/Dashboard/DashboardConverter.php | 20 +++++++++++ .../dashboard/dashboard-converter.blade.php | 28 +++++++++++++++ routes/web.php | 4 +++ 4 files changed, 86 insertions(+) create mode 100644 app/Http/Controllers/DownloadConversionResultController.php diff --git a/app/Http/Controllers/DownloadConversionResultController.php b/app/Http/Controllers/DownloadConversionResultController.php new file mode 100644 index 0000000..3663e95 --- /dev/null +++ b/app/Http/Controllers/DownloadConversionResultController.php @@ -0,0 +1,34 @@ +user_id === $request->user()->id, 403); + abort_unless($conversion->status === ConversionStatus::Completed, 404); + abort_unless($conversion->resultFile !== null, 404); + + $file = $conversion->resultFile; + + if ($file->expires_at !== null && $file->expires_at->isPast()) { + abort(410); + } + + abort_unless(Storage::disk('local')->exists($file->stored_path), 404); + + return Storage::disk('local')->download( + $file->stored_path, + $file->original_name, + ['Content-Type' => $file->mime_type], + ); + } +} diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index f1f970c..a46418b 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -323,6 +323,26 @@ public function getCurrentJobProperty(): ?ConversionJob ->find($this->currentConversionJobId); } + public function convertAnother(): void + { + $this->reset(['upload', 'currentFileId', 'uploadError', 'selectedTargetFormat', + 'selectedConverterKey', 'targetFormatError', 'optionsSchema', 'options', + 'optionsByTarget', 'currentConversionJobId']); + $this->step = 'upload'; + } + + public function convertWithDifferentSettings(): void + { + if ($this->currentFile === null || $this->selectedTargetFormat === null) { + $this->step = 'upload'; + + return; + } + + $this->currentConversionJobId = null; + $this->step = 'settings'; + } + public function refreshConversionStatus(): void { if ($this->currentConversionJobId === null) { diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index 2f40946..7c568d6 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -180,6 +180,34 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor
@endif + @if ($step === 'completed' && $this->currentJob) + @php($job = $this->currentJob) +
+
+

Done! Your file is ready.

+ @if ($job->resultFile) +

{{ $job->resultFile->original_name }}

+ @endif +
+ +
+ Download + + + Change settings + + + + Convert another file + +
+
+ @endif + @if ($step === 'converting')
diff --git a/routes/web.php b/routes/web.php index 45e7e0e..bae479f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('ui-kit'); Route::middleware('auth')->group(function () { + Route::get('/conversions/{conversion}/download', DownloadConversionResultController::class) + ->name('conversions.download'); + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); From e8864f4d5805c10932535e5894c612a7e6351f63 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:50:43 +0300 Subject: [PATCH 10/18] CONV-156: Add failed state test Co-Authored-By: Claude Sonnet 4.6 --- .../Livewire/DashboardConverterConvertTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index 7856629..9347206 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -32,6 +32,22 @@ expect(ConversionJob::query()->count())->toBe(1); }); +it('renders failed state with readable message and no raw error', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->failed()->create([ + 'error_code' => 'driver_failed', + 'error_message' => 'Imagick internal raw error', + ]); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'failed') + ->set('currentConversionJobId', $job->id) + ->assertSee('Conversion failed') + ->assertSee('Try another file') + ->assertDontSee('Imagick internal raw error'); +}); + it('renders converting state while conversion is processing', function () { $user = User::factory()->create(); $job = ConversionJob::factory()->for($user)->processing()->create([ From f901c14de7f14ab265935e849472ae32bd3a9319 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:51:19 +0300 Subject: [PATCH 11/18] CONV-157: Implement failed state UI Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/dashboard-converter.blade.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index 7c568d6..8d52417 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -180,6 +180,25 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor
@endif + @if ($step === 'failed') +
+
+

Conversion failed

+

We could not convert this file. Try another file or change settings.

+
+ +
+ + Change settings + + + + Try another file + +
+
+ @endif + @if ($step === 'completed' && $this->currentJob) @php($job = $this->currentJob)
From dc41406697f4ba36dee8c30dc9d19adb1a0aa761 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:52:02 +0300 Subject: [PATCH 12/18] CONV-158: Add result download route test Co-Authored-By: Claude Sonnet 4.6 --- tests/Feature/ConversionDownloadTest.php | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/Feature/ConversionDownloadTest.php diff --git a/tests/Feature/ConversionDownloadTest.php b/tests/Feature/ConversionDownloadTest.php new file mode 100644 index 0000000..b87a81e --- /dev/null +++ b/tests/Feature/ConversionDownloadTest.php @@ -0,0 +1,102 @@ +create(); + Storage::disk('local')->put('conversions/result.jpg', 'fake-image-data'); + + $resultFile = FileRecord::factory()->for($user)->create([ + 'stored_path' => 'conversions/result.jpg', + 'original_name' => 'result.jpg', + 'mime_type' => 'image/jpeg', + 'expires_at' => now()->addHour(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $resultFile->id, + ]); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertOk(); +}); + +it('does not allow another user to download result', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $other = User::factory()->create(); + + Storage::disk('local')->put('conversions/result.jpg', 'fake-image-data'); + + $resultFile = FileRecord::factory()->for($owner)->create([ + 'stored_path' => 'conversions/result.jpg', + 'expires_at' => now()->addHour(), + ]); + + $job = ConversionJob::factory()->for($owner)->completed()->create([ + 'result_file_id' => $resultFile->id, + ]); + + $this->actingAs($other) + ->get(route('conversions.download', $job)) + ->assertForbidden(); +}); + +it('does not allow downloading a non-completed conversion', function () { + $user = User::factory()->create(); + + $job = ConversionJob::factory()->for($user)->processing()->create(); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertNotFound(); +}); + +it('does not allow downloading a failed conversion', function () { + $user = User::factory()->create(); + + $job = ConversionJob::factory()->for($user)->failed()->create(); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertNotFound(); +}); + +it('does not allow downloading an expired result', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + + Storage::disk('local')->put('conversions/result.jpg', 'fake-image-data'); + + $resultFile = FileRecord::factory()->for($user)->create([ + 'stored_path' => 'conversions/result.jpg', + 'expires_at' => now()->subHour(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $resultFile->id, + ]); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertStatus(410); +}); + +it('requires authentication to download', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->completed()->create(); + + $this->get(route('conversions.download', $job)) + ->assertRedirect(route('login')); +}); From 5d93e3829f0eaa3f6dbdaa578c6f107a82f0d720 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:53:20 +0300 Subject: [PATCH 13/18] CONV-159: Implement result download route Controller implemented in CONV-155. Fixed unused import lint. Co-Authored-By: Claude Sonnet 4.6 --- tests/Feature/ConversionDownloadTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Feature/ConversionDownloadTest.php b/tests/Feature/ConversionDownloadTest.php index b87a81e..310981a 100644 --- a/tests/Feature/ConversionDownloadTest.php +++ b/tests/Feature/ConversionDownloadTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Enums\ConversionStatus; use App\Models\ConversionJob; use App\Models\FileRecord; use App\Models\User; From 100ec731271e70a409288bfd9795df67a09ad0c1 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:53:56 +0300 Subject: [PATCH 14/18] CONV-160: Add convert another action Co-Authored-By: Claude Sonnet 4.6 --- .../Livewire/DashboardConverterConvertTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index 9347206..7454a68 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -118,6 +118,23 @@ ->assertSet('step', 'converting'); }); +it('resets dashboard state when user chooses convert another file', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->completed()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'completed') + ->set('currentConversionJobId', $job->id) + ->set('selectedTargetFormat', 'jpg') + ->set('options', ['quality' => 'high']) + ->call('convertAnother') + ->assertSet('step', 'upload') + ->assertSet('currentConversionJobId', null) + ->assertSet('selectedTargetFormat', null) + ->assertSet('options', []); +}); + it('does not create duplicate conversion jobs on repeated convert calls', function () { Queue::fake(); Storage::fake('local'); From 3d9bd65e1f580085972ba2aab9157119113bbbf4 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:54:34 +0300 Subject: [PATCH 15/18] CONV-161: Add convert with different settings action Co-Authored-By: Claude Sonnet 4.6 --- .../DashboardConverterConvertTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index 7454a68..afe3fc2 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -118,6 +118,26 @@ ->assertSet('step', 'converting'); }); +it('returns to settings while keeping file target and options', function () { + $user = User::factory()->create(); + $file = FileRecord::factory()->for($user)->create(['extension' => 'png']); + $job = ConversionJob::factory()->for($user)->completed()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'completed') + ->set('currentFileId', $file->id) + ->set('selectedTargetFormat', 'jpg') + ->set('options', ['quality' => 'high']) + ->set('currentConversionJobId', $job->id) + ->call('convertWithDifferentSettings') + ->assertSet('step', 'settings') + ->assertSet('currentFileId', $file->id) + ->assertSet('selectedTargetFormat', 'jpg') + ->assertSet('options', ['quality' => 'high']) + ->assertSet('currentConversionJobId', null); +}); + it('resets dashboard state when user chooses convert another file', function () { $user = User::factory()->create(); $job = ConversionJob::factory()->for($user)->completed()->create(); From b9daf0ff470170348287dd96515aa31f0e920ef9 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Mon, 1 Jun 2026 23:55:38 +0300 Subject: [PATCH 16/18] CONV-162: Add result expired UI handling Co-Authored-By: Claude Sonnet 4.6 --- app/Models/FileRecord.php | 5 ++ database/factories/FileRecordFactory.php | 5 ++ .../dashboard/dashboard-converter.blade.php | 47 ++++++++++++------- .../DashboardConverterConvertTest.php | 17 +++++++ 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/app/Models/FileRecord.php b/app/Models/FileRecord.php index 9a60351..0f5b541 100644 --- a/app/Models/FileRecord.php +++ b/app/Models/FileRecord.php @@ -40,6 +40,11 @@ protected function casts(): array ]; } + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/database/factories/FileRecordFactory.php b/database/factories/FileRecordFactory.php index 0f75af3..49960ac 100644 --- a/database/factories/FileRecordFactory.php +++ b/database/factories/FileRecordFactory.php @@ -14,6 +14,11 @@ class FileRecordFactory extends Factory { protected $model = FileRecord::class; + public function expired(): static + { + return $this->state(['expires_at' => now()->subHour()]); + } + public function definition(): array { $extension = fake()->randomElement(['png', 'jpg', 'webp', 'pdf']); diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index 8d52417..cefcec1 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -202,28 +202,39 @@ class="flex items-center justify-between gap-4 rounded-[var(--ca-radius-md)] bor @if ($step === 'completed' && $this->currentJob) @php($job = $this->currentJob)
-
-

Done! Your file is ready.

- @if ($job->resultFile) -

{{ $job->resultFile->original_name }}

- @endif -
- -
- Download - - - Change settings - + @if ($job->resultFile?->isExpired()) +
+

This result has expired

+

Upload the original file again to create a new result.

+
Convert another file -
+ @else +
+

Done! Your file is ready.

+ @if ($job->resultFile) +

{{ $job->resultFile->original_name }}

+ @endif +
+ +
+ Download + + + Change settings + + + + Convert another file + +
+ @endif
@endif diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index afe3fc2..44c919d 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -118,6 +118,23 @@ ->assertSet('step', 'converting'); }); +it('shows expired result message when completed result file is expired', function () { + $user = User::factory()->create(); + $resultFile = FileRecord::factory()->for($user)->expired()->create([ + 'original_name' => 'old-result.jpg', + ]); + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $resultFile->id, + ]); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'completed') + ->set('currentConversionJobId', $job->id) + ->assertSee('This result has expired') + ->assertDontSee('Download'); +}); + it('returns to settings while keeping file target and options', function () { $user = User::factory()->create(); $file = FileRecord::factory()->for($user)->create(['extension' => 'png']); From c42c9e2f1ec6d17a33e54266e5afec4ea6b3fd13 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 09:59:44 +0300 Subject: [PATCH 17/18] CONV-163: Add convert flow integration test Co-Authored-By: Claude Sonnet 4.6 --- tests/Feature/ConversionFlowTest.php | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/Feature/ConversionFlowTest.php diff --git a/tests/Feature/ConversionFlowTest.php b/tests/Feature/ConversionFlowTest.php new file mode 100644 index 0000000..72e20b2 --- /dev/null +++ b/tests/Feature/ConversionFlowTest.php @@ -0,0 +1,47 @@ +create(); + + $component = Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('upload', UploadedFile::fake()->image('avatar.png', 800, 600)) + ->assertSet('step', 'format') + ->call('selectTargetFormat', 'jpg') + ->assertSet('step', 'settings') + ->set('options.quality', 'high') + ->call('continueFromSettings') + ->assertSet('step', 'convert') + ->call('convert') + ->assertSet('step', 'converting'); + + $job = ConversionJob::query()->firstOrFail(); + + (new ProcessConversionJob($job->id))->handle( + app(ConverterDriverRegistry::class), + app(RecordConversionResultFileAction::class), + ); + + $component + ->call('refreshConversionStatus') + ->assertSet('step', 'completed') + ->assertSee('Download'); + + $this->actingAs($user) + ->get(route('conversions.download', $job->fresh())) + ->assertOk(); +}); From 63c7984032ef2e7a6a66d8f898f714c33a9f75ed Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 10:20:28 +0300 Subject: [PATCH 18/18] Phase 11 review fixes: convert error handling, stale job ID, type safety, polling timeout Co-Authored-By: Claude Sonnet 4.6 --- .../DownloadConversionResultController.php | 6 +-- app/Livewire/Dashboard/DashboardConverter.php | 45 ++++++++++++++++--- tests/Feature/ConversionDownloadTest.php | 33 ++++++++++++++ .../DashboardConverterConvertTest.php | 26 +++++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/DownloadConversionResultController.php b/app/Http/Controllers/DownloadConversionResultController.php index 3663e95..fe32167 100644 --- a/app/Http/Controllers/DownloadConversionResultController.php +++ b/app/Http/Controllers/DownloadConversionResultController.php @@ -13,15 +13,13 @@ final class DownloadConversionResultController extends Controller { public function __invoke(Request $request, ConversionJob $conversion) { - abort_unless($conversion->user_id === $request->user()->id, 403); + abort_unless((int) $conversion->user_id === (int) $request->user()->id, 403); abort_unless($conversion->status === ConversionStatus::Completed, 404); abort_unless($conversion->resultFile !== null, 404); $file = $conversion->resultFile; - if ($file->expires_at !== null && $file->expires_at->isPast()) { - abort(410); - } + abort_if($file->isExpired(), 410); abort_unless(Storage::disk('local')->exists($file->stored_path), 404); diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index a46418b..a6f751a 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -9,6 +9,7 @@ use App\Exceptions\Files\UnsupportedFileFormatException; use App\Models\ConversionJob; use App\Models\FileRecord; +use App\Support\Conversions\Exceptions\UnsupportedConversionException; use App\Support\Converters\ConverterRegistry; use App\Support\Converters\DTO\ConverterTarget; use App\Support\Converters\Exceptions\InvalidConverterOptionsException; @@ -17,6 +18,7 @@ use App\ViewModels\TargetFormatCardViewModel; use Livewire\Component; use Livewire\WithFileUploads; +use Throwable; class DashboardConverter extends Component { @@ -53,6 +55,10 @@ class DashboardConverter extends Component public ?int $currentConversionJobId = null; + public int $pollCount = 0; + + public ?string $convertError = null; + public function updatedUpload(): void { $this->storeUpload(app(StoreUploadedFileAction::class)); @@ -216,14 +222,28 @@ public function convert(): void return; } - $job = app(CreateConversionJobAction::class)->handle( - user: auth()->user(), - sourceFile: $file, - targetFormat: $this->selectedTargetFormat, - options: $this->options, - ); + $this->convertError = null; + + try { + $job = app(CreateConversionJobAction::class)->handle( + user: auth()->user(), + sourceFile: $file, + targetFormat: $this->selectedTargetFormat, + options: $this->options, + ); + } catch (UnsupportedConversionException $e) { + $this->convertError = 'This conversion is not supported. Please choose a different format.'; + $this->step = 'format'; + + return; + } catch (Throwable) { + $this->convertError = 'Something went wrong. Please try again.'; + + return; + } $this->currentConversionJobId = $job->id; + $this->pollCount = 0; $this->step = 'converting'; } @@ -333,13 +353,15 @@ public function convertAnother(): void public function convertWithDifferentSettings(): void { + $this->currentConversionJobId = null; + $this->pollCount = 0; + if ($this->currentFile === null || $this->selectedTargetFormat === null) { $this->step = 'upload'; return; } - $this->currentConversionJobId = null; $this->step = 'settings'; } @@ -349,6 +371,15 @@ public function refreshConversionStatus(): void return; } + $this->pollCount++; + + if ($this->pollCount > 60) { + $this->step = 'failed'; + $this->convertError = 'Conversion is taking too long. Please try again.'; + + return; + } + $job = ConversionJob::query() ->where('user_id', auth()->id()) ->find($this->currentConversionJobId); diff --git a/tests/Feature/ConversionDownloadTest.php b/tests/Feature/ConversionDownloadTest.php index 310981a..6150f87 100644 --- a/tests/Feature/ConversionDownloadTest.php +++ b/tests/Feature/ConversionDownloadTest.php @@ -92,6 +92,39 @@ ->assertStatus(410); }); +it('returns 404 when completed job has no result file', function () { + $user = User::factory()->create(); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => null, + ]); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertNotFound(); +}); + +it('returns 404 when result file is missing from storage', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + + $resultFile = FileRecord::factory()->for($user)->create([ + 'stored_path' => 'conversions/missing.jpg', + 'original_name' => 'missing.jpg', + 'mime_type' => 'image/jpeg', + 'expires_at' => now()->addHour(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $resultFile->id, + ]); + + $this->actingAs($user) + ->get(route('conversions.download', $job)) + ->assertNotFound(); +}); + it('requires authentication to download', function () { $user = User::factory()->create(); $job = ConversionJob::factory()->for($user)->completed()->create(); diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php index 44c919d..eea0615 100644 --- a/tests/Feature/Livewire/DashboardConverterConvertTest.php +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -118,6 +118,32 @@ ->assertSet('step', 'converting'); }); +it('transitions to failed when polling exceeds max attempts', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->processing()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'converting') + ->set('currentConversionJobId', $job->id) + ->set('pollCount', 60) + ->call('refreshConversionStatus') + ->assertSet('step', 'failed'); +}); + +it('clears stale job id when going back to different settings without a file', function () { + $user = User::factory()->create(); + $job = ConversionJob::factory()->for($user)->completed()->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('step', 'completed') + ->set('currentConversionJobId', $job->id) + ->call('convertWithDifferentSettings') + ->assertSet('step', 'upload') + ->assertSet('currentConversionJobId', null); +}); + it('shows expired result message when completed result file is expired', function () { $user = User::factory()->create(); $resultFile = FileRecord::factory()->for($user)->expired()->create([