diff --git a/app/Http/Controllers/DownloadConversionResultController.php b/app/Http/Controllers/DownloadConversionResultController.php new file mode 100644 index 0000000..fe32167 --- /dev/null +++ b/app/Http/Controllers/DownloadConversionResultController.php @@ -0,0 +1,32 @@ +user_id === (int) $request->user()->id, 403); + abort_unless($conversion->status === ConversionStatus::Completed, 404); + abort_unless($conversion->resultFile !== null, 404); + + $file = $conversion->resultFile; + + abort_if($file->isExpired(), 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 ac97713..a6f751a 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -2,10 +2,14 @@ namespace App\Livewire\Dashboard; +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\Conversions\Exceptions\UnsupportedConversionException; use App\Support\Converters\ConverterRegistry; use App\Support\Converters\DTO\ConverterTarget; use App\Support\Converters\Exceptions\InvalidConverterOptionsException; @@ -14,6 +18,7 @@ use App\ViewModels\TargetFormatCardViewModel; use Livewire\Component; use Livewire\WithFileUploads; +use Throwable; class DashboardConverter extends Component { @@ -48,6 +53,12 @@ class DashboardConverter extends Component */ public array $optionsByTarget = []; + public ?int $currentConversionJobId = null; + + public int $pollCount = 0; + + public ?string $convertError = null; + public function updatedUpload(): void { $this->storeUpload(app(StoreUploadedFileAction::class)); @@ -196,11 +207,46 @@ 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; + } + + $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'; + } + public function validateSettings(): bool { $this->resetErrorBag(); @@ -285,6 +331,74 @@ 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 convertAnother(): void + { + $this->reset(['upload', 'currentFileId', 'uploadError', 'selectedTargetFormat', + 'selectedConverterKey', 'targetFormatError', 'optionsSchema', 'options', + 'optionsByTarget', 'currentConversionJobId']); + $this->step = 'upload'; + } + + public function convertWithDifferentSettings(): void + { + $this->currentConversionJobId = null; + $this->pollCount = 0; + + if ($this->currentFile === null || $this->selectedTargetFormat === null) { + $this->step = 'upload'; + + return; + } + + $this->step = 'settings'; + } + + public function refreshConversionStatus(): void + { + if ($this->currentConversionJobId === null) { + 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); + + 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/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/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/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 b60b2b3..cefcec1 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -180,6 +180,83 @@ 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) +
+ @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 + + @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)
@@ -191,8 +268,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/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'); diff --git a/tests/Feature/ConversionDownloadTest.php b/tests/Feature/ConversionDownloadTest.php new file mode 100644 index 0000000..6150f87 --- /dev/null +++ b/tests/Feature/ConversionDownloadTest.php @@ -0,0 +1,134 @@ +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('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(); + + $this->get(route('conversions.download', $job)) + ->assertRedirect(route('login')); +}); 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(); +}); diff --git a/tests/Feature/Livewire/DashboardConverterConvertTest.php b/tests/Feature/Livewire/DashboardConverterConvertTest.php new file mode 100644 index 0000000..eea0615 --- /dev/null +++ b/tests/Feature/Livewire/DashboardConverterConvertTest.php @@ -0,0 +1,218 @@ +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); +}); + +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([ + '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('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(); + + 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('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([ + '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']); + $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(); + + 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'); + + $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); +});