Phase 3 asset storage management

This commit is contained in:
Codex
2026-07-10 13:13:25 -05:00
parent a72d2aa164
commit 084b945e75
13 changed files with 225 additions and 20 deletions
+5
View File
@@ -37,6 +37,11 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
TSE_ATTACHMENT_DISK=public
TSE_ATTACHMENT_ROOT=attachments
TSE_ATTACHMENT_MAX_KB=10240
TSE_ATTACHMENT_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip
CACHE_STORE=database CACHE_STORE=database
# CACHE_PREFIX= # CACHE_PREFIX=
+1
View File
@@ -13,6 +13,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments. - Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments.
- Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout. - Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout.
- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states. - Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states.
- Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests.
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
+59 -5
View File
@@ -8,32 +8,46 @@ use App\Models\JobPost;
use App\Models\ProviderProfile; use App\Models\ProviderProfile;
use App\Models\SocialPost; use App\Models\SocialPost;
use App\Models\WorkOrder; use App\Models\WorkOrder;
use App\Models\Attachment;
use App\Models\BuyerProfile;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class AttachmentController extends Controller class AttachmentController extends Controller
{ {
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$maxKb = (int) config('provider-exchange.attachments.max_kb');
$mimeTypes = (array) config('provider-exchange.attachments.allowed_mime_types');
$data = $request->validate([ $data = $request->validate([
'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'], 'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'],
'attachable_id' => ['required', 'integer'], 'attachable_id' => ['required', 'integer'],
'kind' => ['nullable', 'string', 'max:255'], 'kind' => ['nullable', 'string', 'max:255'],
'caption' => ['nullable', 'string', 'max:1000'], 'caption' => ['nullable', 'string', 'max:1000'],
'file' => ['required', 'file', 'max:10240'], 'file' => ['required', 'file', 'max:'.$maxKb, 'mimetypes:'.implode(',', $mimeTypes)],
]); ]);
$attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']); $attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']);
$this->authorizeAttachment($request, $attachable); $this->authorizeAttachment($request, $attachable);
$file = $request->file('file'); $file = $request->file('file');
$path = $file->store('attachments/'.Str::slug($data['attachable_type']), 'public'); $disk = (string) config('provider-exchange.attachments.disk');
$root = trim((string) config('provider-exchange.attachments.root'), '/');
$directory = implode('/', array_filter([
$root ?: 'attachments',
Str::slug($data['attachable_type']),
now()->format('Y'),
now()->format('m'),
]));
$path = $file->store($directory, $disk);
$attachable->attachments()->create([ $attachable->attachments()->create([
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'kind' => $data['kind'] ?? 'general', 'kind' => $data['kind'] ?? 'general',
'disk' => 'public', 'disk' => $disk,
'path' => $path, 'path' => $path,
'original_name' => $file->getClientOriginalName(), 'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getClientMimeType(), 'mime_type' => $file->getClientMimeType(),
@@ -44,11 +58,35 @@ class AttachmentController extends Controller
return back()->with('status', 'File uploaded.'); return back()->with('status', 'File uploaded.');
} }
public function destroy(Request $request, Attachment $attachment): RedirectResponse
{
$this->authorizeAttachment($request, $attachment->attachable);
if (
$attachment->user_id === $request->user()->id
|| $request->user()->hasRole('admin')
|| $this->ownsAttachable($request, $attachment->attachable)
) {
$disk = $attachment->disk;
$path = $attachment->path;
$attachment->delete();
$stillReferenced = Attachment::where('disk', $disk)->where('path', $path)->exists();
if (! $stillReferenced) {
Storage::disk($disk)->delete($path);
}
return back()->with('status', 'File deleted.');
}
abort(403);
}
private function resolveAttachable(string $type, int $id): mixed private function resolveAttachable(string $type, int $id): mixed
{ {
return match ($type) { return match ($type) {
'provider_profile' => ProviderProfile::findOrFail($id), 'provider_profile' => ProviderProfile::findOrFail($id),
'buyer_profile' => \App\Models\BuyerProfile::findOrFail($id), 'buyer_profile' => BuyerProfile::findOrFail($id),
'social_post' => SocialPost::findOrFail($id), 'social_post' => SocialPost::findOrFail($id),
'job_post' => JobPost::findOrFail($id), 'job_post' => JobPost::findOrFail($id),
'work_order' => WorkOrder::findOrFail($id), 'work_order' => WorkOrder::findOrFail($id),
@@ -63,7 +101,7 @@ class AttachmentController extends Controller
if ($attachable instanceof ProviderProfile) { if ($attachable instanceof ProviderProfile) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof \App\Models\BuyerProfile) { } elseif ($attachable instanceof BuyerProfile) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
} elseif ($attachable instanceof SocialPost) { } elseif ($attachable instanceof SocialPost) {
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
@@ -77,4 +115,20 @@ class AttachmentController extends Controller
abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403); abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403);
} }
} }
private function ownsAttachable(Request $request, mixed $attachable): bool
{
$userId = $request->user()->id;
return match (true) {
$attachable instanceof ProviderProfile => $attachable->user_id === $userId,
$attachable instanceof BuyerProfile => $attachable->user_id === $userId,
$attachable instanceof SocialPost => $attachable->user_id === $userId,
$attachable instanceof JobPost => $attachable->buyer_id === $userId,
$attachable instanceof WorkOrder => in_array($userId, [$attachable->buyer_id, $attachable->provider_id], true),
$attachable instanceof Dispute => in_array($userId, [$attachable->workOrder->buyer_id, $attachable->workOrder->provider_id], true),
$attachable instanceof ExternalProfileImport => $attachable->providerProfile->user_id === $userId,
default => false,
};
}
} }
+30
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
#[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])] #[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])]
class Attachment extends Model class Attachment extends Model
@@ -19,4 +20,33 @@ class Attachment extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function publicUrl(): string
{
if ($this->disk === 'public') {
return asset('storage/'.$this->path);
}
return Storage::disk($this->disk)->url($this->path);
}
public function formattedSize(): string
{
$bytes = max(0, (int) $this->size);
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1).' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 1).' KB';
}
return $bytes.' B';
}
public function isImage(): bool
{
return str_starts_with((string) $this->mime_type, 'image/');
}
} }
+13
View File
@@ -0,0 +1,13 @@
<?php
return [
'attachments' => [
'disk' => env('TSE_ATTACHMENT_DISK', 'public'),
'root' => trim(env('TSE_ATTACHMENT_ROOT', 'attachments'), '/'),
'max_kb' => (int) env('TSE_ATTACHMENT_MAX_KB', 10240),
'allowed_mime_types' => array_values(array_filter(array_map(
'trim',
explode(',', env('TSE_ATTACHMENT_MIME_TYPES', 'image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip'))
))),
],
];
+9
View File
@@ -27,6 +27,12 @@ This document records the explicit runtime requirements and server changes for P
- `storage/` - `storage/`
- `bootstrap/cache/` - `bootstrap/cache/`
- the SQLite database file for SQLite test deployments. - the SQLite database file for SQLite test deployments.
- Public attachment URLs require `php artisan storage:link`; the update script now runs it idempotently.
- Attachment policy environment variables:
- `TSE_ATTACHMENT_DISK`, default `public`.
- `TSE_ATTACHMENT_ROOT`, default `attachments`.
- `TSE_ATTACHMENT_MAX_KB`, default `10240`.
- `TSE_ATTACHMENT_MIME_TYPES`, comma-separated allowlist.
## Local Test Requirements ## Local Test Requirements
@@ -71,6 +77,9 @@ Initial test data/runtime:
- `CACHE_STORE=database`. - `CACHE_STORE=database`.
- `QUEUE_CONNECTION=database`. - `QUEUE_CONNECTION=database`.
- `MAIL_MAILER=log`. - `MAIL_MAILER=log`.
- `TSE_ATTACHMENT_DISK=public`.
- `TSE_ATTACHMENT_ROOT=attachments`.
- `TSE_ATTACHMENT_MAX_KB=10240`.
## ChristIT Deployment Log ## ChristIT Deployment Log
+2
View File
@@ -39,6 +39,8 @@ The preferred working loop for this project is:
- The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer. - The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer.
- Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`. - Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`.
- Public attachment serving needs `php artisan storage:link`; the checked-in update script runs this automatically.
- Tune upload policy with `TSE_ATTACHMENT_DISK`, `TSE_ATTACHMENT_ROOT`, `TSE_ATTACHMENT_MAX_KB`, and `TSE_ATTACHMENT_MIME_TYPES`.
- Review `.env` before production use. - Review `.env` before production use.
- Configure real mail before inviting users. - Configure real mail before inviting users.
- Move sessions, cache, and queues to Redis before serious traffic. - Move sessions, cache, and queues to Redis before serious traffic.
+14 -9
View File
@@ -13,6 +13,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- Quote revisions, quote decline flow, and quote acceptance. - Quote revisions, quote decline flow, and quote acceptance.
- Work-order creation from accepted quotes. - Work-order creation from accepted quotes.
- Work-order status transitions, messages, attachments, deliverables, and completion notes. - Work-order status transitions, messages, attachments, deliverables, and completion notes.
- Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion.
- Mutual reviews with category metrics. - Mutual reviews with category metrics.
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
- Universal community ratings for buyers, providers, job posts, work orders, and disputes. - Universal community ratings for buyers, providers, job posts, work orders, and disputes.
@@ -35,15 +36,17 @@ Provider Exchange is a Laravel application for a provider-centered field-service
1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries. 1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries.
2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages. 2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages.
3. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets. 3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling.
4. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views. 4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets.
5. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools. 5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views.
6. Notifications: channel preferences, email templates, and event-specific subscription controls. 6. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools.
7. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions. 7. Notifications: channel preferences, email templates, and event-specific subscription controls.
8. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. 8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions.
9. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows. 9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history.
10. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes. 10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals.
11. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability. 11. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows.
12. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes.
13. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability.
## Scaling Risks To Track ## Scaling Risks To Track
@@ -52,6 +55,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- Notifications should move from the database queue to Redis-backed queues before high-volume alerting or email delivery. - Notifications should move from the database queue to Redis-backed queues before high-volume alerting or email delivery.
- Sessions, cache, and queues should move to Redis before running more than one web node. - Sessions, cache, and queues should move to Redis before running more than one web node.
- Uploaded evidence, profile images, and deliverable files should move to shared or object storage before multi-server deployment. - Uploaded evidence, profile images, and deliverable files should move to shared or object storage before multi-server deployment.
- Attachment records already track disk/path metadata, but production scaling still needs object-storage lifecycle rules, upload malware scanning, quota policy, and thumbnail generation.
- Work-order timelines, dispute activity, messages, and notification tables need careful indexes before large production use. - Work-order timelines, dispute activity, messages, and notification tables need careful indexes before large production use.
- Realtime chat, websocket presence, mobile push, and geolocation check-ins should be added as deliberate service layers instead of bolted onto normal page requests. - Realtime chat, websocket presence, mobile push, and geolocation check-ins should be added as deliberate service layers instead of bolted onto normal page requests.
- Background imports, image processing, moderation summaries, and reputation recalculation should run through queue workers rather than web requests. - Background imports, image processing, moderation summaries, and reputation recalculation should run through queue workers rather than web requests.
@@ -65,3 +69,4 @@ Provider Exchange is a Laravel application for a provider-centered field-service
- Real-time chat/websocket presence. - Real-time chat/websocket presence.
- Marketplace-wide dispute binding beyond voluntary community reputation. - Marketplace-wide dispute binding beyond voluntary community reputation.
- Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear. - Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear.
- Automated competency promotion rules beyond transparent, reviewable tag and level signals.
@@ -1,11 +1,21 @@
@props(['type', 'id', 'kind' => 'general']) @props(['type', 'id', 'kind' => 'general'])
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded border p-4"> @php
$maxMb = round(((int) config('provider-exchange.attachments.max_kb')) / 1024, 1);
$mimeTypes = implode(', ', config('provider-exchange.attachments.allowed_mime_types'));
@endphp
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded-md border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
@csrf @csrf
<input type="hidden" name="attachable_type" value="{{ $type }}"> <input type="hidden" name="attachable_type" value="{{ $type }}">
<input type="hidden" name="attachable_id" value="{{ $id }}"> <input type="hidden" name="attachable_id" value="{{ $id }}">
<input type="hidden" name="kind" value="{{ $kind }}"> <input type="hidden" name="kind" value="{{ $kind }}">
<x-field name="caption" label="Caption" /> <x-field name="caption" label="Caption" />
<input type="file" name="file" required class="block w-full text-sm"> <div>
<label for="file-{{ $type }}-{{ $id }}-{{ $kind }}" class="block text-sm font-medium text-slate-800 dark:text-slate-200">File</label>
<input id="file-{{ $type }}-{{ $id }}-{{ $kind }}" type="file" name="file" required class="mt-1 block w-full text-sm text-slate-700 file:me-4 file:rounded-md file:border-0 file:bg-sky-600 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-sky-700 dark:text-slate-300">
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Max {{ $maxMb }} MB. Allowed: {{ $mimeTypes }}.</p>
<x-input-error :messages="$errors->get('file')" class="mt-2" />
</div>
<x-primary-button>Upload file</x-primary-button> <x-primary-button>Upload file</x-primary-button>
</form> </form>
@@ -3,10 +3,33 @@
@if ($attachments->count()) @if ($attachments->count())
<div class="mt-4 grid gap-3 md:grid-cols-2"> <div class="mt-4 grid gap-3 md:grid-cols-2">
@foreach ($attachments as $attachment) @foreach ($attachments as $attachment)
<a href="{{ asset('storage/'.$attachment->path) }}" class="block rounded border p-3 text-sm" target="_blank"> <div class="rounded-md border border-slate-200 bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900">
<span class="font-semibold">{{ $attachment->original_name }}</span> @if ($attachment->isImage())
<span class="block text-gray-500">{{ $attachment->caption }}</span> <a href="{{ $attachment->publicUrl() }}" target="_blank" class="block overflow-hidden rounded-md border border-slate-200 dark:border-slate-800">
<img src="{{ $attachment->publicUrl() }}" alt="{{ $attachment->caption ?: $attachment->original_name }}" class="h-36 w-full object-cover">
</a> </a>
@endif
<div class="{{ $attachment->isImage() ? 'mt-3' : '' }} flex items-start justify-between gap-3">
<a href="{{ $attachment->publicUrl() }}" class="font-semibold text-sky-700 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-200" target="_blank">
{{ $attachment->original_name }}
</a>
@auth
@if (auth()->id() === $attachment->user_id || auth()->user()->hasRole('admin'))
<form method="POST" action="{{ route('attachments.destroy', $attachment) }}">
@csrf
@method('DELETE')
<button type="submit" class="text-xs font-semibold text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">Delete</button>
</form>
@endif
@endauth
</div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ $attachment->kind }} | {{ $attachment->mime_type ?: 'unknown type' }} | {{ $attachment->formattedSize() }}
</p>
@if ($attachment->caption)
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $attachment->caption }}</p>
@endif
</div>
@endforeach @endforeach
</div> </div>
@endif @endif
+1
View File
@@ -62,6 +62,7 @@ Route::middleware('auth')->group(function () {
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store'); Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store');
Route::post('/comments', [CommentController::class, 'store'])->name('comments.store'); Route::post('/comments', [CommentController::class, 'store'])->name('comments.store');
Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store'); Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->name('attachments.destroy');
Route::post('/ratings', [RatingController::class, 'store'])->name('ratings.store'); Route::post('/ratings', [RatingController::class, 'store'])->name('ratings.store');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
+1
View File
@@ -10,6 +10,7 @@ composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
npm ci npm ci
npm run build npm run build
php artisan migrate --force php artisan migrate --force
php artisan storage:link || true
php artisan optimize:clear php artisan optimize:clear
php artisan optimize php artisan optimize
+51
View File
@@ -251,6 +251,57 @@ class ExchangeWorkflowTest extends TestCase
$this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']); $this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']);
} }
public function test_attachment_uploads_follow_storage_policy_and_can_be_deleted(): void
{
Storage::fake('public');
config()->set('provider-exchange.attachments.disk', 'public');
config()->set('provider-exchange.attachments.root', 'test-assets');
config()->set('provider-exchange.attachments.max_kb', 1024);
config()->set('provider-exchange.attachments.allowed_mime_types', ['image/jpeg', 'image/png']);
$provider = $this->userWithRole('provider');
$profile = $provider->providerProfile()->create(['business_name' => 'Asset Provider']);
$this->actingAs($provider)->post('/attachments', [
'attachable_type' => 'provider_profile',
'attachable_id' => $profile->id,
'kind' => 'profile',
'caption' => 'Profile asset',
'file' => UploadedFile::fake()->image('profile.jpg'),
])->assertRedirect();
$attachment = $profile->attachments()->firstOrFail();
$this->assertSame('public', $attachment->disk);
$this->assertStringStartsWith('test-assets/provider-profile/', $attachment->path);
Storage::disk('public')->assertExists($attachment->path);
$this->actingAs($provider)->delete("/attachments/{$attachment->id}")
->assertRedirect();
$this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
Storage::disk('public')->assertMissing($attachment->path);
}
public function test_attachment_upload_rejects_disallowed_mime_types(): void
{
Storage::fake('public');
config()->set('provider-exchange.attachments.allowed_mime_types', ['image/jpeg']);
$provider = $this->userWithRole('provider');
$profile = $provider->providerProfile()->create(['business_name' => 'Mime Provider']);
$this->actingAs($provider)->from('/provider-profile')->post('/attachments', [
'attachable_type' => 'provider_profile',
'attachable_id' => $profile->id,
'kind' => 'profile',
'file' => UploadedFile::fake()->create('payload.zip', 1, 'application/zip'),
])->assertRedirect('/provider-profile')
->assertSessionHasErrors('file');
$this->assertDatabaseCount('attachments', 0);
}
public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void
{ {
[$buyer, $provider, $workOrder] = $this->workOrderFixture(); [$buyer, $provider, $workOrder] = $this->workOrderFixture();