Commit d396f784 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(uploads): a temporary file that is gone is a sentence, not a 500

Error eab81218-6482-47eb-9d09-aa54259069aa: an expense receipt, from an
iPhone, at 16:22 today. League\Flysystem\UnableToRetrieveMetadata —
"Unable to retrieve the file_size for livewire-tmp/Vy05fpe….jpg".

Livewire uploads in two steps: the file lands in livewire-tmp on its own
request, and the component reads it on a later one. Between those two the
file can be gone — every push redeploys the container and livewire-tmp is
not persistent, Livewire's own cleanup removes stale files, and a phone
happily resends a form after the app has restarted. The first thing to
touch the file is validation, because `max:5120` calls getSize(), so the
receptionist standing at the desk with a receipt got an error page and a
support code instead of a form.

The file being gone is not exceptional, it is Tuesday. ChecksTemporaryUploads
asks whether the pending upload still exists (treating an unreachable disk
as gone rather than letting a storage exception reach the browser), clears
the dead handle so the next attempt starts clean, and puts one Arabic
sentence on the field: اختر الملف مرة أخرى وأعد الرفع.

Applied to every component that reads an upload, not just the one that was
reported — expense receipt and expense form, the three portal uploads
(payment proof, documents, requests), branding images, the page builder,
the gallery, the document wizard, the event wizard's cover and gallery
photos, and the participant import.

Also pins what the group roster already does with combined invoices, since
it was worth proving rather than assuming: participants 219 and 97 each
paid part of the federation card on an invoice shared with a kit, typed as
free text, and the roster allocates the payment across the lines and shows
the card's share against the price for that member's tier — 2,500 of 8,000
and 2,000 of 6,000, both labelled أقساط.

Full suite 325 tests on SQLite and on the restored tenant, no failures.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent fe003fd4
<?php
namespace App\Domain\Shared\Traits;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
/**
* A file the browser uploaded a minute ago is not guaranteed to still be there
* when the form is submitted.
*
* Livewire uploads in two steps: the file lands in `livewire-tmp` on its own
* request, and the component reads it on a later one. Between the two, the
* temporary file can be gone — the container was redeployed (every push
* redeploys, and the tmp directory is not persistent), Livewire's own cleanup
* removed it, or the phone resent the form after the app had restarted.
*
* What happened then was a 500. Every read of the file — `max:5120` in the
* rules calls getSize(), so validation is the first to touch it — throws
* Flysystem's UnableToRetrieveMetadata, which nothing catches. A receptionist
* standing at the desk with a receipt on their phone sees an error page and a
* support code (eab81218-…, 2026-09-02, an expense receipt from an iPhone).
*
* The file being gone is not an exceptional condition, it is Tuesday. It gets
* a sentence in Arabic asking for the file again, and the property is cleared
* so the next attempt starts clean.
*/
trait ChecksTemporaryUploads
{
/**
* True when there is a pending upload on this property and its backing
* file has vanished.
*
* A non-upload value (null, or an already-stored path) is not missing —
* there is simply nothing to check.
*/
protected function uploadHasVanished(mixed $file): bool
{
if ($file instanceof TemporaryUploadedFile) {
try {
return ! $file->exists();
} catch (\Throwable) {
// The disk itself is unreachable: treat it as gone rather than
// letting a storage exception reach the browser.
return true;
}
}
if (is_array($file)) {
foreach ($file as $one) {
if ($this->uploadHasVanished($one)) {
return true;
}
}
}
return false;
}
/**
* Guard a submit handler. Returns false when the caller should stop,
* having already told the user what to do.
*/
protected function temporaryUploadIsUsable(string $property, mixed $file): bool
{
if (! $this->uploadHasVanished($file)) {
return true;
}
$this->{$property} = is_array($file) ? [] : null;
$this->addError($property, __('انتهت صلاحية الملف المرفوع — من فضلك اختر الملف مرة أخرى وأعد الرفع.'));
return false;
}
}
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
...@@ -19,6 +20,7 @@ ...@@ -19,6 +20,7 @@
class DocumentUploadWizard extends Component class DocumentUploadWizard extends Component
{ {
use WithFileUploads; use WithFileUploads;
use ChecksTemporaryUploads;
public int $currentStep = 1; public int $currentStep = 1;
public int $totalSteps = 4; public int $totalSteps = 4;
...@@ -63,6 +65,10 @@ public function mount(string $documentableType, int $documentableId): void ...@@ -63,6 +65,10 @@ public function mount(string $documentableType, int $documentableId): void
public function nextStep(): void public function nextStep(): void
{ {
if (! $this->temporaryUploadIsUsable('file', $this->file)) {
return;
}
$this->validate($this->rulesForStep($this->currentStep)); $this->validate($this->rulesForStep($this->currentStep));
$this->currentStep = min($this->currentStep + 1, $this->totalSteps); $this->currentStep = min($this->currentStep + 1, $this->totalSteps);
} }
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
...@@ -24,6 +25,7 @@ ...@@ -24,6 +25,7 @@
class CreateEventWizard extends Component class CreateEventWizard extends Component
{ {
use WithFileUploads, UsesBranchScope; use WithFileUploads, UsesBranchScope;
use ChecksTemporaryUploads;
#[Locked] #[Locked]
public ?Event $event = null; public ?Event $event = null;
...@@ -168,6 +170,14 @@ private function validateStep(?int $step = null): void ...@@ -168,6 +170,14 @@ private function validateStep(?int $step = null): void
}; };
if (! empty($rules)) { if (! empty($rules)) {
// Both upload properties: a cover picked on step 3 and gallery
// photos added earlier can each outlive their temporary file.
foreach (['coverPhoto' => $this->coverPhoto, 'galleryPhotos' => $this->galleryPhotos] as $field => $pending) {
if (! $this->temporaryUploadIsUsable($field, $pending)) {
return;
}
}
$this->validate($rules, [ $this->validate($rules, [
'title.required' => 'عنوان الحدث مطلوب', 'title.required' => 'عنوان الحدث مطلوب',
'type.required' => 'نوع الحدث مطلوب', 'type.required' => 'نوع الحدث مطلوب',
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Financial\Enums\ExpenseCategory; use App\Domain\Financial\Enums\ExpenseCategory;
use App\Domain\Financial\Services\ExpenseService; use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
...@@ -15,7 +16,7 @@ ...@@ -15,7 +16,7 @@
#[Title('تسجيل مصروف')] #[Title('تسجيل مصروف')]
class ExpenseForm extends Component class ExpenseForm extends Component
{ {
use UsesBranchScope, WithFileUploads; use UsesBranchScope, WithFileUploads, ChecksTemporaryUploads;
public string $category = ''; public string $category = '';
public string $amount_display = ''; public string $amount_display = '';
...@@ -70,6 +71,10 @@ public function removeAttachment(): void ...@@ -70,6 +71,10 @@ public function removeAttachment(): void
public function save(ExpenseService $service): void public function save(ExpenseService $service): void
{ {
if (! $this->temporaryUploadIsUsable('attachment', $this->attachment)) {
return;
}
$this->validate(); $this->validate();
try { try {
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Financial\Models\Expense; use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Services\ExpenseService; use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
...@@ -16,7 +17,7 @@ ...@@ -16,7 +17,7 @@
#[Title('تفاصيل المصروف')] #[Title('تفاصيل المصروف')]
class ExpenseShow extends Component class ExpenseShow extends Component
{ {
use UsesBranchScope, WithFileUploads; use UsesBranchScope, WithFileUploads, ChecksTemporaryUploads;
#[Locked] #[Locked]
public string $uuid = ''; public string $uuid = '';
...@@ -74,6 +75,10 @@ public function messages(): array ...@@ -74,6 +75,10 @@ public function messages(): array
public function uploadReceipt(ExpenseService $service): void public function uploadReceipt(ExpenseService $service): void
{ {
$this->authorize('expenses.create'); $this->authorize('expenses.create');
if (! $this->temporaryUploadIsUsable('receipt', $this->receipt)) {
return;
}
$this->validate(); $this->validate();
try { try {
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
...@@ -17,6 +18,7 @@ ...@@ -17,6 +18,7 @@
class ParticipantImport extends Component class ParticipantImport extends Component
{ {
use WithFileUploads, UsesBranchScope; use WithFileUploads, UsesBranchScope;
use ChecksTemporaryUploads;
public $file; public $file;
public array $preview = []; public array $preview = [];
...@@ -40,6 +42,10 @@ public function rules(): array ...@@ -40,6 +42,10 @@ public function rules(): array
public function updatedFile(): void public function updatedFile(): void
{ {
if (! $this->temporaryUploadIsUsable('file', $this->file)) {
return;
}
$this->validate(); $this->validate();
$this->preview = []; $this->preview = [];
$this->importErrors = []; $this->importErrors = [];
...@@ -75,6 +81,10 @@ public function updatedFile(): void ...@@ -75,6 +81,10 @@ public function updatedFile(): void
public function import(): void public function import(): void
{ {
if (! $this->temporaryUploadIsUsable('file', $this->file)) {
return;
}
$this->validate(); $this->validate();
$this->processing = true; $this->processing = true;
$this->imported = 0; $this->imported = 0;
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use App\Livewire\Portal\Concerns\PortalScreen; use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
...@@ -26,7 +27,7 @@ ...@@ -26,7 +27,7 @@
#[Title('المستندات')] #[Title('المستندات')]
class PortalDocuments extends Component class PortalDocuments extends Component
{ {
use PortalScreen, WithFileUploads; use PortalScreen, WithFileUploads, ChecksTemporaryUploads;
public string $documentType = 'medical_certificate'; public string $documentType = 'medical_certificate';
...@@ -62,6 +63,10 @@ protected function messages(): array ...@@ -62,6 +63,10 @@ protected function messages(): array
public function upload(): void public function upload(): void
{ {
if (! $this->temporaryUploadIsUsable('file', $this->file)) {
return;
}
$this->validate(); $this->validate();
$participantId = $this->activeParticipantId(); $participantId = $this->activeParticipantId();
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
...@@ -24,7 +25,7 @@ ...@@ -24,7 +25,7 @@
#[Title('تسجيل تحويل')] #[Title('تسجيل تحويل')]
class PortalPayProof extends Component class PortalPayProof extends Component
{ {
use PortalScreen, WithFileUploads; use PortalScreen, WithFileUploads, ChecksTemporaryUploads;
#[Locked] #[Locked]
public int $invoiceId; public int $invoiceId;
...@@ -84,6 +85,10 @@ protected function messages(): array ...@@ -84,6 +85,10 @@ protected function messages(): array
public function submit(PaymentProofService $proofs): void public function submit(PaymentProofService $proofs): void
{ {
if (! $this->temporaryUploadIsUsable('proof', $this->proof)) {
return;
}
$this->validate(); $this->validate();
$invoice = Invoice::findOrFail($this->invoiceId); $invoice = Invoice::findOrFail($this->invoiceId);
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
...@@ -27,7 +28,7 @@ ...@@ -27,7 +28,7 @@
#[Title('تقديم طلب')] #[Title('تقديم طلب')]
class PortalRequests extends Component class PortalRequests extends Component
{ {
use PortalScreen, WithFileUploads; use PortalScreen, WithFileUploads, ChecksTemporaryUploads;
public string $type = 'excuse'; public string $type = 'excuse';
...@@ -91,6 +92,10 @@ protected function messages(): array ...@@ -91,6 +92,10 @@ protected function messages(): array
public function submit(ServiceRequestService $requests): void public function submit(ServiceRequestService $requests): void
{ {
if (! $this->temporaryUploadIsUsable('attachment', $this->attachment)) {
return;
}
$this->validate(); $this->validate();
if ($this->type === 'excuse' && ! $this->sessionId) { if ($this->type === 'excuse' && ! $this->sessionId) {
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
...@@ -19,6 +20,7 @@ ...@@ -19,6 +20,7 @@
class BrandingSettings extends Component class BrandingSettings extends Component
{ {
use WithFileUploads; use WithFileUploads;
use ChecksTemporaryUploads;
// Logo uploads // Logo uploads
public $logo; public $logo;
...@@ -211,6 +213,13 @@ public function save(): void ...@@ -211,6 +213,13 @@ public function save(): void
foreach ($uploads as $field => $file) { foreach ($uploads as $field => $file) {
if ($file) { if ($file) {
// The tmp file can be gone by the time Save is pressed — a
// redeploy between picking the logo and saving is enough. Ask
// for it again instead of throwing a Flysystem error.
if (! $this->temporaryUploadIsUsable($field, $file)) {
continue;
}
$oldPath = $settings->get("branding.{$field}"); $oldPath = $settings->get("branding.{$field}");
if ($oldPath && Storage::disk('public')->exists($oldPath)) { if ($oldPath && Storage::disk('public')->exists($oldPath)) {
Storage::disk('public')->delete($oldPath); Storage::disk('public')->delete($oldPath);
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
#[Layout('layouts.app')] #[Layout('layouts.app')]
...@@ -15,6 +16,7 @@ ...@@ -15,6 +16,7 @@
class GalleryManager extends Component class GalleryManager extends Component
{ {
use WithFileUploads; use WithFileUploads;
use ChecksTemporaryUploads;
public $photos = []; public $photos = [];
public array $gallery = []; public array $gallery = [];
...@@ -44,6 +46,10 @@ public function loadGallery(): void ...@@ -44,6 +46,10 @@ public function loadGallery(): void
public function updatedPhotos(): void public function updatedPhotos(): void
{ {
if (! $this->temporaryUploadIsUsable('photos', $this->photos)) {
return;
}
$this->validate([ $this->validate([
'photos.*' => 'image|max:1536|mimes:jpg,jpeg,png,webp', 'photos.*' => 'image|max:1536|mimes:jpg,jpeg,png,webp',
]); ]);
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
use App\Domain\Website\Models\WebsiteBlock; use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsitePage; use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\WebsiteBlockService; use App\Domain\Website\Services\WebsiteBlockService;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
...@@ -20,6 +21,7 @@ ...@@ -20,6 +21,7 @@
class PageBuilder extends Component class PageBuilder extends Component
{ {
use WithFileUploads; use WithFileUploads;
use ChecksTemporaryUploads;
public WebsitePage $page; public WebsitePage $page;
...@@ -156,6 +158,10 @@ public function updatedUpload(): void ...@@ -156,6 +158,10 @@ public function updatedUpload(): void
{ {
$this->authorize('settings.manage'); $this->authorize('settings.manage');
if (! $this->temporaryUploadIsUsable('upload', $this->upload)) {
return;
}
$this->validate([ $this->validate([
'upload' => 'image|max:4096', 'upload' => 'image|max:4096',
], [ ], [
......
...@@ -51,6 +51,53 @@ public function test_the_group_roster_renders_and_names_the_subscription_column( ...@@ -51,6 +51,53 @@ public function test_the_group_roster_renders_and_names_the_subscription_column(
$response->assertDontSee('دليل ألوان الدفع', escape: false); $response->assertDontSee('دليل ألوان الدفع', escape: false);
} }
public function test_a_card_paid_inside_a_combined_invoice_shows_on_the_roster(): void
{
// The money for the federation card is routinely taken on the same
// invoice as a kit or a bag, and typed as a free-text line. The column
// has to allocate that invoice's payment across its lines and show the
// card's share — production participants 219 (2,500 of 8,000,
// non-member) and 97 (2,000 of 6,000, member) are both that case.
$billing = app(\App\Domain\Financial\Services\ParticipantBillingService::class);
foreach ([219 => 800000, 97 => 600000] as $participantId => $expectedTotal) {
$participant = \App\Domain\Participant\Models\Participant::withoutGlobalScopes()->find($participantId);
if (! $participant) {
continue;
}
$facts = $billing->bundledProductForParticipants(
[$participantId],
2,
'قيد اشتراك فريق اتحاد الكرة',
null,
true,
)[$participantId] ?? null;
$this->assertNotNull($facts, "Participant {$participantId} paid toward the card on a shared invoice.");
$this->assertGreaterThan(0, $facts['paid'], 'The card gets its share of the invoice payment.');
$this->assertTrue($facts['from_text'], 'The line was typed by hand, which is the whole point.');
$enrollment = \App\Domain\Training\Models\Enrollment::withoutGlobalScopes()
->where('participant_id', $participantId)->where('status', 'active')->first();
if (! $enrollment) {
continue;
}
$group = \App\Domain\Training\Models\TrainingGroup::withoutGlobalScopes()->find($enrollment->training_group_id);
$html = $this->actingAs($this->anAdmin())->get(route('groups.show', $group))->getContent();
$paid = number_format($facts['paid'] / 100, 0);
$total = number_format($expectedTotal / 100, 0);
$this->assertStringContainsString($paid, $html);
$this->assertStringContainsString($total, $html, 'The total is the price for this member tier, not the instalment.');
$this->assertStringContainsString('أقساط', $html, 'Being paid off is not the same as never bought.');
}
}
public function test_the_pos_terminal_renders_with_engine_prices(): void public function test_the_pos_terminal_renders_with_engine_prices(): void
{ {
$cashier = User::query()->get()->first(fn (User $u) => $u->can('pos.sell')); $cashier = User::query()->get()->first(fn (User $u) => $u->can('pos.sell'));
......
<?php
namespace Tests\Unit;
use App\Domain\Shared\Traits\ChecksTemporaryUploads;
use Tests\TestCase;
/**
* A pending upload whose temporary file has gone.
*
* This is not a hypothetical: every push redeploys the container, and
* `livewire-tmp` does not survive that. A receptionist who picks a receipt on
* their phone and presses Save a minute later hit a 500 with a support code
* (eab81218-…, an expense receipt, 2 September) because validation's `max:5120`
* calls getSize() on a file that is no longer there.
*
* Boots the framework because the message it produces goes through __().
*/
class ChecksTemporaryUploadsTest extends TestCase
{
private function subject(): object
{
return new class {
use ChecksTemporaryUploads;
public mixed $receipt = null;
public array $errors = [];
public function addError(string $key, string $message): void
{
$this->errors[$key] = $message;
}
public function check(): bool
{
return $this->temporaryUploadIsUsable('receipt', $this->receipt);
}
};
}
private function upload(bool $exists): object
{
return new class($exists) extends \Livewire\Features\SupportFileUploads\TemporaryUploadedFile {
public function __construct(private bool $isThere)
{
// Deliberately not calling the parent constructor: this stands
// in for a temporary file, and touching the disk is the very
// thing under test.
}
public function exists(): bool
{
return $this->isThere;
}
};
}
public function test_a_file_that_is_still_there_passes_through(): void
{
$component = $this->subject();
$component->receipt = $this->upload(exists: true);
$this->assertTrue($component->check());
$this->assertSame([], $component->errors);
}
public function test_a_vanished_file_is_refused_with_a_sentence_not_an_exception(): void
{
$component = $this->subject();
$component->receipt = $this->upload(exists: false);
$this->assertFalse($component->check());
$this->assertArrayHasKey('receipt', $component->errors);
$this->assertStringContainsString('اختر الملف مرة أخرى', $component->errors['receipt']);
$this->assertNull($component->receipt, 'The dead handle is cleared so the next attempt starts clean.');
}
public function test_a_storage_failure_counts_as_gone_rather_than_blowing_up(): void
{
$component = $this->subject();
$component->receipt = new class extends \Livewire\Features\SupportFileUploads\TemporaryUploadedFile {
public function __construct() {}
public function exists(): bool
{
throw new \RuntimeException('disk unreachable');
}
};
$this->assertFalse($component->check());
}
public function test_an_array_of_uploads_is_refused_if_any_one_is_gone(): void
{
$component = $this->subject();
$component->receipt = [$this->upload(exists: true), $this->upload(exists: false)];
$this->assertFalse($component->check());
$this->assertSame([], $component->receipt, 'An array property is cleared to an array.');
}
public function test_nothing_pending_is_not_a_missing_file(): void
{
// An empty field is the validator's business, not this guard's.
$component = $this->subject();
$component->receipt = null;
$this->assertTrue($component->check());
$this->assertSame([], $component->errors);
}
public function test_an_already_stored_path_is_left_alone(): void
{
$component = $this->subject();
$component->receipt = 'expenses/receipts/already-saved.jpg';
$this->assertTrue($component->check());
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment