diff --git a/app/Livewire/ConversionHistoryTable.php b/app/Livewire/ConversionHistoryTable.php new file mode 100644 index 0000000..c215708 --- /dev/null +++ b/app/Livewire/ConversionHistoryTable.php @@ -0,0 +1,182 @@ +resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function updatedSourceFormat(): void + { + $this->resetPage(); + } + + public function updatedTargetFormat(): void + { + $this->resetPage(); + } + + public function updatedDateFrom(): void + { + $this->resetPage(); + } + + public function updatedDateTo(): void + { + $this->resetPage(); + } + + public function statusOptions(): array + { + return [ + 'all' => 'All', + 'queued' => 'Queued', + 'processing' => 'Processing', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled', + 'expired' => 'Expired', + ]; + } + + public function formatOptions(): array + { + return [ + 'all' => 'All formats', + 'png' => 'PNG', + 'jpg' => 'JPG', + 'webp' => 'WEBP', + 'pdf' => 'PDF', + 'gif' => 'GIF', + 'bmp' => 'BMP', + 'svg' => 'SVG', + 'tiff' => 'TIFF', + ]; + } + + public function jobs(): LengthAwarePaginator + { + return ConversionJob::query() + ->where('user_id', auth()->id()) + ->with(['sourceFile', 'resultFile', 'creditCharge']) + ->when(trim($this->search) !== '', function (Builder $query) { + $search = '%'.trim($this->search).'%'; + + $query->where(function (Builder $query) use ($search) { + $query + ->where('source_format', 'like', $search) + ->orWhere('target_format', 'like', $search) + ->orWhereHas('sourceFile', fn (Builder $q) => $q->where('original_name', 'like', $search)) + ->orWhereHas('resultFile', fn (Builder $q) => $q->where('original_name', 'like', $search)); + }); + }) + ->when($this->status !== 'all', fn (Builder $query) => $query->where('status', $this->status)) + ->when($this->sourceFormat !== 'all', fn (Builder $query) => $query->where('source_format', $this->sourceFormat)) + ->when($this->targetFormat !== 'all', fn (Builder $query) => $query->where('target_format', $this->targetFormat)) + ->when($this->dateFrom, fn (Builder $query) => $query->whereDate('created_at', '>=', $this->dateFrom)) + ->when($this->dateTo, fn (Builder $query) => $query->whereDate('created_at', '<=', $this->dateTo)) + ->latest() + ->paginate(15); + } + + public function canDownload(ConversionJob $job): bool + { + return $job->isCompleted() + && $job->result_file_id !== null + && $job->resultFile !== null + && ! $job->resultFile->isExpired(); + } + + public function canConvertAgain(ConversionJob $job): bool + { + return $job->sourceFile !== null && ! $job->sourceFile->isExpired(); + } + + public function convertAgain(int $jobId): void + { + $job = ConversionJob::query() + ->where('user_id', auth()->id()) + ->with('sourceFile') + ->find($jobId); + + if (! $job || ! $this->canConvertAgain($job)) { + $this->dispatch('toast', type: 'error', message: 'Original file expired. Upload it again to repeat this conversion.'); + + return; + } + + $this->dispatch('conversion-repeat-requested', conversionJobId: $job->id); + $this->redirectRoute('dashboard'); + } + + public function statusBadgeVariant(ConversionStatus $status): string + { + return match ($status) { + ConversionStatus::Queued => 'purple', + ConversionStatus::Processing => 'warning', + ConversionStatus::Completed => 'success', + ConversionStatus::Failed => 'danger', + default => 'neutral', + }; + } + + public function formatBytes(?int $bytes): string + { + if ($bytes === null) { + return '—'; + } + + if ($bytes >= 1_000_000_000) { + return round($bytes / 1_000_000_000, 1).' GB'; + } + + if ($bytes >= 1_000_000) { + return round($bytes / 1_000_000, 1).' MB'; + } + + if ($bytes >= 1_000) { + return round($bytes / 1_000).' KB'; + } + + return $bytes.' B'; + } + + public function render(): View + { + return view('livewire.conversion-history-table', [ + 'jobs' => $this->jobs(), + ]); + } +} diff --git a/resources/views/history/index.blade.php b/resources/views/history/index.blade.php new file mode 100644 index 0000000..31e08ee --- /dev/null +++ b/resources/views/history/index.blade.php @@ -0,0 +1,10 @@ + +
+

Conversion History

+

Find and reuse your previous conversions.

+
+ +
+ +
+
diff --git a/resources/views/livewire/conversion-history-table.blade.php b/resources/views/livewire/conversion-history-table.blade.php new file mode 100644 index 0000000..139cf5f --- /dev/null +++ b/resources/views/livewire/conversion-history-table.blade.php @@ -0,0 +1,135 @@ +
+ {{-- Filters --}} +
+ + + + + + + + + + + +
+ + {{-- Table --}} +
+ @if ($jobs->isEmpty()) +
+

No conversions found

+

Try adjusting your filters or upload a file to start converting

+
+ @else +
+ + + + + + + + + + + + + + + + @foreach ($jobs as $job) + + + + + + + + + + + + @endforeach + +
File NameFromToSizeCreatedCompletedStatusCreditsActions
+ {{ $job->sourceFile?->original_name ?? '—' }} + + {{ $job->source_format ? strtoupper($job->source_format) : '—' }} + + {{ $job->target_format ? strtoupper($job->target_format) : '—' }} + + {{ $this->formatBytes($job->sourceFile?->size_bytes) }} + + {{ $job->created_at->format('M d, Y H:i') }} + + {{ $job->completed_at?->format('M d, Y H:i') ?? '—' }} + + + {{ ucfirst($job->status->value) }} + + + @if ($job->creditCharge !== null && $job->creditCharge->captured_amount !== null) + {{ $job->creditCharge->captured_amount }} {{ $job->creditCharge->captured_amount === 1 ? 'credit' : 'credits' }} + @else + — + @endif + +
+ @if ($this->canDownload($job)) + Download + @endif + + @if ($this->canConvertAgain($job)) + + @endif +
+
+
+ +
+ {{ $jobs->links() }} +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index eea5ae5..46c4ec5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,9 @@ Route::view('/billing/credits/success', 'billing.credits-success')->name('billing.credits.success'); Route::view('/billing/credits/cancel', 'billing.credits-cancel')->name('billing.credits.cancel'); + Route::view('/history', 'history.index') + ->name('history'); + Route::get('/conversions/{conversion}/download', DownloadConversionResultController::class) ->name('conversions.download'); diff --git a/tests/Feature/History/HistoryPageAccessTest.php b/tests/Feature/History/HistoryPageAccessTest.php new file mode 100644 index 0000000..88b3667 --- /dev/null +++ b/tests/Feature/History/HistoryPageAccessTest.php @@ -0,0 +1,19 @@ +get('/history') + ->assertRedirect('/login'); +}); + +it('allows authenticated user to access history page', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/history') + ->assertOk() + ->assertSee('Conversion History'); +}); diff --git a/tests/Feature/History/HistoryPageSmokeTest.php b/tests/Feature/History/HistoryPageSmokeTest.php new file mode 100644 index 0000000..612bf46 --- /dev/null +++ b/tests/Feature/History/HistoryPageSmokeTest.php @@ -0,0 +1,65 @@ +create(); + + $source = FileRecord::factory()->for($user)->create([ + 'original_name' => 'final-smoke.png', + ]); + + $result = FileRecord::factory()->for($user)->create([ + 'original_name' => 'final-smoke.jpg', + 'expires_at' => now()->addDay(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'source_file_id' => $source->id, + 'result_file_id' => $result->id, + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->create([ + 'captured_amount' => 1, + 'status' => 'captured', + ]); + + $this->actingAs($user) + ->get('/history') + ->assertOk() + ->assertSee('Conversion History') + ->assertSee('final-smoke.png') + ->assertSee('PNG') + ->assertSee('JPG') + ->assertSee('Completed') + ->assertSee('1 credit'); +}); + +it('does not leak other user conversion data on history page', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + $ownFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'my-file.png', + ]); + + $otherFile = FileRecord::factory()->for($otherUser)->create([ + 'original_name' => 'their-file.png', + ]); + + ConversionJob::factory()->for($user)->for($ownFile, 'sourceFile')->create(); + ConversionJob::factory()->for($otherUser)->for($otherFile, 'sourceFile')->create(); + + $this->actingAs($user) + ->get('/history') + ->assertOk() + ->assertSee('my-file.png') + ->assertDontSee('their-file.png'); +}); diff --git a/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php b/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php new file mode 100644 index 0000000..7b517a6 --- /dev/null +++ b/tests/Feature/Livewire/ConversionHistoryTableActionsTest.php @@ -0,0 +1,85 @@ +create(); + + $result = FileRecord::factory()->for($user)->create([ + 'original_name' => 'photo.jpg', + 'expires_at' => now()->addDay(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $result->id, + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('Download') + ->assertSee(route('conversions.download', $job)); +}); + +it('does not show active download for expired result', function () { + $user = User::factory()->create(); + + $result = FileRecord::factory()->for($user)->create([ + 'original_name' => 'photo.jpg', + 'expires_at' => now()->subDay(), + ]); + + ConversionJob::factory()->for($user)->completed()->create([ + 'result_file_id' => $result->id, + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertDontSee('Download'); +}); + +it('does not show download for failed job', function () { + $user = User::factory()->create(); + + ConversionJob::factory()->for($user)->failed()->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertDontSee('Download'); +}); + +it('shows convert again for completed job with available source file', function () { + $user = User::factory()->create(); + + $source = FileRecord::factory()->for($user)->create([ + 'expires_at' => now()->addDay(), + ]); + + $job = ConversionJob::factory()->for($user)->completed()->for($source, 'sourceFile')->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('Convert Again') + ->call('convertAgain', $job->id) + ->assertDispatched('conversion-repeat-requested', conversionJobId: $job->id) + ->assertRedirect(route('dashboard')); +}); + +it('does not show convert again when source file is expired', function () { + $user = User::factory()->create(); + + $source = FileRecord::factory()->for($user)->create([ + 'expires_at' => now()->subDay(), + ]); + + ConversionJob::factory()->for($user)->completed()->for($source, 'sourceFile')->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertDontSee('Convert Again'); +}); diff --git a/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php b/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php new file mode 100644 index 0000000..aa615d7 --- /dev/null +++ b/tests/Feature/Livewire/ConversionHistoryTableFiltersTest.php @@ -0,0 +1,187 @@ +create(); + + $matchingFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'marketing-banner.png', + ]); + + $otherFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'invoice.pdf', + ]); + + ConversionJob::factory()->for($user)->for($matchingFile, 'sourceFile')->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + ConversionJob::factory()->for($user)->for($otherFile, 'sourceFile')->create([ + 'source_format' => 'pdf', + 'target_format' => 'jpg', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('search', 'marketing') + ->assertSee('marketing-banner.png') + ->assertDontSee('invoice.pdf'); +}); + +it('filters history by search query on format', function () { + $user = User::factory()->create(); + + $pngFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'image.png', + ]); + + $pdfFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'document.pdf', + ]); + + ConversionJob::factory()->for($user)->for($pngFile, 'sourceFile')->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + ConversionJob::factory()->for($user)->for($pdfFile, 'sourceFile')->create([ + 'source_format' => 'pdf', + 'target_format' => 'jpg', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('search', 'png') + ->assertSee('image.png') + ->assertDontSee('document.pdf'); +}); + +it('filters history by status', function () { + $user = User::factory()->create(); + + $completedSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'completed-file.png', + ]); + + $failedSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'failed-file.png', + ]); + + ConversionJob::factory()->for($user)->completed()->for($completedSource, 'sourceFile')->create(); + ConversionJob::factory()->for($user)->failed()->for($failedSource, 'sourceFile')->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('status', 'completed') + ->assertSee('completed-file.png') + ->assertDontSee('failed-file.png'); +}); + +it('filters history by date range', function () { + $user = User::factory()->create(); + + $insideFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'inside-range.png', + ]); + + $outsideFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'outside-range.png', + ]); + + ConversionJob::factory()->for($user)->for($insideFile, 'sourceFile')->create([ + 'created_at' => now()->subDays(3), + ]); + + ConversionJob::factory()->for($user)->for($outsideFile, 'sourceFile')->create([ + 'created_at' => now()->subDays(30), + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('dateFrom', now()->subDays(7)->toDateString()) + ->set('dateTo', now()->toDateString()) + ->assertSee('inside-range.png') + ->assertDontSee('outside-range.png'); +}); + +it('filters history by source format', function () { + $user = User::factory()->create(); + + $pngSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'png-source-file.png', + ]); + + $jpgSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'jpg-source-file.jpg', + ]); + + ConversionJob::factory()->for($user)->for($pngSource, 'sourceFile')->create([ + 'source_format' => 'png', + 'target_format' => 'webp', + ]); + + ConversionJob::factory()->for($user)->for($jpgSource, 'sourceFile')->create([ + 'source_format' => 'jpg', + 'target_format' => 'webp', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('sourceFormat', 'png') + ->assertSee('png-source-file.png') + ->assertDontSee('jpg-source-file.jpg'); +}); + +it('filters history by target format', function () { + $user = User::factory()->create(); + + $jpgTargetSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'to-jpg-file.png', + ]); + + $webpTargetSource = FileRecord::factory()->for($user)->create([ + 'original_name' => 'to-webp-file.png', + ]); + + ConversionJob::factory()->for($user)->for($jpgTargetSource, 'sourceFile')->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + ConversionJob::factory()->for($user)->for($webpTargetSource, 'sourceFile')->create([ + 'source_format' => 'png', + 'target_format' => 'webp', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->set('targetFormat', 'jpg') + ->assertSee('to-jpg-file.png') + ->assertDontSee('to-webp-file.png'); +}); + +it('resets pagination when filter changes', function () { + $user = User::factory()->create(); + + for ($i = 0; $i < 20; $i++) { + ConversionJob::factory()->for($user)->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + } + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->call('gotoPage', 2) + ->assertSet('paginators.page', 2) + ->set('search', 'something') + ->assertSet('paginators.page', 1); +}); diff --git a/tests/Feature/Livewire/ConversionHistoryTableTest.php b/tests/Feature/Livewire/ConversionHistoryTableTest.php new file mode 100644 index 0000000..eaf0423 --- /dev/null +++ b/tests/Feature/Livewire/ConversionHistoryTableTest.php @@ -0,0 +1,146 @@ +create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertOk(); +}); + +it('shows only current user conversion jobs in history', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + $ownFile = FileRecord::factory()->for($user)->create([ + 'original_name' => 'own-image.png', + ]); + + $otherFile = FileRecord::factory()->for($otherUser)->create([ + 'original_name' => 'other-image.png', + ]); + + ConversionJob::factory() + ->for($user) + ->for($ownFile, 'sourceFile') + ->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + ConversionJob::factory() + ->for($otherUser) + ->for($otherFile, 'sourceFile') + ->create([ + 'source_format' => 'png', + 'target_format' => 'jpg', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('own-image.png') + ->assertDontSee('other-image.png'); +}); + +it('renders conversion history table columns', function () { + $user = User::factory()->create(); + + $source = FileRecord::factory()->for($user)->create([ + 'original_name' => 'photo.png', + 'size_bytes' => 102400, + ]); + + $result = FileRecord::factory()->for($user)->create([ + 'original_name' => 'photo.jpg', + 'size_bytes' => 51200, + ]); + + ConversionJob::factory()->for($user)->create([ + 'source_file_id' => $source->id, + 'result_file_id' => $result->id, + 'source_format' => 'png', + 'target_format' => 'jpg', + 'status' => ConversionStatus::Completed, + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('photo.png') + ->assertSee('PNG') + ->assertSee('JPG') + ->assertSee('Completed'); +}); + +it('renders status badges in history table', function () { + $user = User::factory()->create(); + + ConversionJob::factory()->for($user)->completed()->create(); + ConversionJob::factory()->for($user)->failed()->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('Completed') + ->assertSee('Failed'); +}); + +it('renders captured credit cost in history table', function () { + $user = User::factory()->create(); + + $job = ConversionJob::factory()->for($user)->completed()->create(); + + ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->create([ + 'captured_amount' => 2, + 'status' => 'captured', + ]); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('2 credits'); +}); + +it('renders dash when job has no captured credit charge', function () { + $user = User::factory()->create(); + + ConversionJob::factory()->for($user)->failed()->create(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class) + ->assertSee('—'); +}); + +it('eager-loads sourceFile resultFile and creditCharge without N+1 queries', function () { + $user = User::factory()->create(); + + for ($i = 0; $i < 5; $i++) { + $source = FileRecord::factory()->for($user)->create(); + $result = FileRecord::factory()->for($user)->create(); + $job = ConversionJob::factory()->for($user)->completed()->create([ + 'source_file_id' => $source->id, + 'result_file_id' => $result->id, + ]); + ConversionCreditCharge::factory()->for($user)->for($job, 'conversionJob')->captured()->create(); + } + + DB::enableQueryLog(); + + Livewire::actingAs($user) + ->test(ConversionHistoryTable::class); + + $queryCount = count(DB::getQueryLog()); + DB::disableQueryLog(); + + // With eager loading: auth check + jobs + sourceFile + resultFile + creditCharge + pagination count = ~6-7 queries. + // N+1 would produce 3 queries per row (15+ queries for 5 rows). + expect($queryCount)->toBeLessThan(10); +});