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);
+});