From 252dea7016b961e81fb692730d6001412f878dda Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 13 Sep 2026 15:47:09 +0930 Subject: [PATCH] Track media storage quota via an explicit lifecycle enum Charge the raw upload size immediately (enforce on raw, never under-count), correct down to the optimized size in the finalize job, and refund on delete. Each transition is guarded by media.quota_status so retries can't double-apply. --- app/Enums/MediaQuotaStatus.php | 43 ++++ app/Http/Controllers/Api/ApiV1Controller.php | 10 +- .../Controllers/Api/ApiV1Dot1Controller.php | 5 +- app/Http/Controllers/Api/ApiV2Controller.php | 5 +- app/Http/Controllers/ComposeController.php | 5 +- .../Controllers/DirectMessageController.php | 8 +- .../ImageOptimizePipeline/ImageOptimize.php | 2 + .../ImageOptimizePipeline/ImageUpdate.php | 3 + .../MediaPipeline/MediaDeletePipeline.php | 11 +- app/Jobs/VideoPipeline/VideoThumbnail.php | 3 + app/Models/Media.php | 2 + app/Services/UserStorageService.php | 101 +++++++++ config/scheduledtasks.php | 11 +- ..._add_storage_accounting_to_media_table.php | 47 +++++ .../Feature/Media/MediaStorageChargeTest.php | 123 +++++++++++ .../MediaPipeline/MediaDeletePipelineTest.php | 84 +++++++- .../Services/UserStorageServiceTest.php | 195 ++++++++++++++++++ 17 files changed, 635 insertions(+), 23 deletions(-) create mode 100644 app/Enums/MediaQuotaStatus.php create mode 100644 database/migrations/2026_09_13_000000_add_storage_accounting_to_media_table.php create mode 100644 tests/Feature/Media/MediaStorageChargeTest.php diff --git a/app/Enums/MediaQuotaStatus.php b/app/Enums/MediaQuotaStatus.php new file mode 100644 index 000000000..6cc06f52f --- /dev/null +++ b/app/Enums/MediaQuotaStatus.php @@ -0,0 +1,43 @@ +media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + $media->original_size = $photo->getSize(); $media->mime = $mime; $media->caption = $request->input('description') ?? ''; $media->filter_class = $filterClass; @@ -2153,6 +2154,8 @@ class ApiV1Controller extends Controller } $media->save(); + UserStorageService::chargeOriginal($media); + switch ($media->mime) { case 'image/jpg': case 'image/jpeg': @@ -2170,8 +2173,6 @@ class ApiV1Controller extends Controller break; } - UserStorageService::increaseStorageUsed($user->id, $fileSize); - Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer); $res = $this->fractal->createData($resource)->toArray(); @@ -2388,6 +2389,7 @@ class ApiV1Controller extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + $media->original_size = $photo->getSize(); $media->mime = $mime; $media->caption = $request->input('description') ?? ''; $media->filter_class = $filterClass; @@ -2397,6 +2399,8 @@ class ApiV1Controller extends Controller } $media->save(); + UserStorageService::chargeOriginal($media); + switch ($media->mime) { case 'image/jpg': case 'image/jpeg': @@ -2414,8 +2418,6 @@ class ApiV1Controller extends Controller break; } - UserStorageService::increaseStorageUsed($user->id, $fileSize); - Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer); $res = $this->fractal->createData($resource)->toArray(); diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index 44ed827d3..e4c86e6c0 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -1301,6 +1301,7 @@ class ApiV1Dot1Controller extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + $media->original_size = $photo->getSize(); $media->mime = $mime; $media->order = 1; $media->caption = $request->input('description'); @@ -1309,6 +1310,8 @@ class ApiV1Dot1Controller extends Controller } $media->save(); + UserStorageService::chargeOriginal($media); + switch ($media->mime) { case 'image/jpg': case 'image/jpeg': @@ -1326,8 +1329,6 @@ class ApiV1Dot1Controller extends Controller break; } - UserStorageService::increaseStorageUsed($user->id, $fileSize); - NewStatusPipeline::dispatch($status); Cache::forget('user:account:id:'.$user->id); diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 5a96e2d76..437ce0fba 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -309,6 +309,7 @@ class ApiV2Controller extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + $media->original_size = $photo->getSize(); $media->mime = $mime; $media->caption = $request->input('description'); $media->filter_class = $filterClass; @@ -318,6 +319,8 @@ class ApiV2Controller extends Controller } $media->save(); + UserStorageService::chargeOriginal($media); + switch ($media->mime) { case 'image/jpg': case 'image/jpeg': @@ -335,8 +338,6 @@ class ApiV2Controller extends Controller break; } - UserStorageService::increaseStorageUsed($user->id, $fileSize); - Cache::forget($limitKey); $fractal = new Fractal\Manager; $fractal->setSerializer(new ArraySerializer); diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 2cf59daa2..3fcec6e56 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -124,6 +124,7 @@ class ComposeController extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + $media->original_size = $photo->getSize(); $media->caption = ''; $media->mime = $mime; $media->filter_class = $filterClass; @@ -131,6 +132,8 @@ class ComposeController extends Controller $media->version = '3'; $media->save(); + UserStorageService::chargeOriginal($media); + $preview_url = $media->url().'?v='.time(); $url = $media->url().'?v='.time(); @@ -153,8 +156,6 @@ class ComposeController extends Controller break; } - UserStorageService::increaseStorageUsed($user->id, $fileSize); - Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer); $res = $this->fractal->createData($resource)->toArray(); diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index 22b0a2cfb..8f0d901e0 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -499,6 +499,10 @@ class DirectMessageController extends Controller $media->media_path = $path; $media->original_sha256 = $hash; $media->size = $photo->getSize(); + // DM media is not run through the optimize/finalize pipeline, so its + // on-disk size equals the raw upload size. original_size mirrors it for + // consistency with the other upload paths. + $media->original_size = $photo->getSize(); $media->mime = $photo->getMimeType(); $media->caption = null; $media->filter_class = null; @@ -526,7 +530,9 @@ class DirectMessageController extends Controller ] ); - UserStorageService::increaseStorageUsed($user->id, $fileSize); + // DM media has no finalize job; charge the raw stored size. It stays at + // OriginalSize (no optimize correction) which is the on-disk footprint. + UserStorageService::chargeOriginal($media); if ($recipient->domain) { $this->remoteDeliver($dm); diff --git a/app/Jobs/ImageOptimizePipeline/ImageOptimize.php b/app/Jobs/ImageOptimizePipeline/ImageOptimize.php index 9d2685c53..ea1083c22 100644 --- a/app/Jobs/ImageOptimizePipeline/ImageOptimize.php +++ b/app/Jobs/ImageOptimizePipeline/ImageOptimize.php @@ -59,6 +59,8 @@ class ImageOptimize implements ShouldQueue $localFs = config('filesystems.default') === 'local'; + // A skip_optimize image never reaches ImageUpdate, so its quota stays at + // the raw size charged on upload (which is its on-disk footprint). if ($localFs) { $path = storage_path('app/'.$media->media_path); if (! is_file($path) || $media->skip_optimize) { diff --git a/app/Jobs/ImageOptimizePipeline/ImageUpdate.php b/app/Jobs/ImageOptimizePipeline/ImageUpdate.php index 012b7e2c9..ac6cbf7cf 100644 --- a/app/Jobs/ImageOptimizePipeline/ImageUpdate.php +++ b/app/Jobs/ImageOptimizePipeline/ImageUpdate.php @@ -4,6 +4,7 @@ namespace App\Jobs\ImageOptimizePipeline; use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Models\Media; +use App\Services\UserStorageService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -104,6 +105,8 @@ class ImageUpdate implements ShouldQueue $total = ($photo_size + $thumb_size); $media->size = $total; $media->save(); + + UserStorageService::chargeOptimized($media); } catch (\Exception $e) { if (config('app.dev_log')) { Log::error('Failed to calculate media sizes: '.$e->getMessage()); diff --git a/app/Jobs/MediaPipeline/MediaDeletePipeline.php b/app/Jobs/MediaPipeline/MediaDeletePipeline.php index 7ffa8438f..3268b01e6 100644 --- a/app/Jobs/MediaPipeline/MediaDeletePipeline.php +++ b/app/Jobs/MediaPipeline/MediaDeletePipeline.php @@ -93,8 +93,6 @@ class MediaDeletePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue return 1; } - $ownerUserId = $media->user_id; - $ownerMediaSize = (int) $media->size; $path = $media->media_path; $thumb = $media->thumbnail_path; @@ -148,11 +146,12 @@ class MediaDeletePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue } } - $media->delete(); + // Refund the owner's storage_used by whatever this media currently + // reflects (raw or optimized, per quota_status). Read before delete + // so quota_status is intact; guarded so it refunds at most once. + UserStorageService::subtractMedia($media); - if ($ownerUserId) { - UserStorageService::decrementStorageUsed($ownerUserId, $ownerMediaSize); - } + $media->delete(); } catch (\Exception $e) { Log::warning('MediaDeletePipeline: Failed to delete media', [ 'media_id' => $media->id, diff --git a/app/Jobs/VideoPipeline/VideoThumbnail.php b/app/Jobs/VideoPipeline/VideoThumbnail.php index cb98f391c..9ccf3337d 100644 --- a/app/Jobs/VideoPipeline/VideoThumbnail.php +++ b/app/Jobs/VideoPipeline/VideoThumbnail.php @@ -139,6 +139,9 @@ class VideoThumbnail implements ShouldBeUniqueUntilProcessing, ShouldQueue StatusService::del($media->status_id); } + // Video is not size-optimized, so the raw size charged at upload already + // reflects the on-disk footprint; no quota correction is needed here. + MediaStoragePipeline::dispatch($media); } } diff --git a/app/Models/Media.php b/app/Models/Media.php index f02f341fd..e315c0144 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\MediaQuotaStatus; use App\Util\Media\License; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,7 @@ class Media extends Model 'srcset' => 'array', 'deleted_at' => 'datetime', 'skip_optimize' => 'boolean', + 'quota_status' => MediaQuotaStatus::class, ]; } diff --git a/app/Services/UserStorageService.php b/app/Services/UserStorageService.php index 178d6d4fc..18f5e93c3 100644 --- a/app/Services/UserStorageService.php +++ b/app/Services/UserStorageService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Enums\MediaQuotaStatus; use App\Models\Media; use App\Models\User; use Carbon\Carbon; @@ -117,6 +118,106 @@ class UserStorageService return $updatedVal; } + /** + * Charge the raw upload size of a freshly uploaded media to its owner's + * storage_used, moving it Pending -> OriginalSize. + * + * Called synchronously at upload (after the media row is saved) so the + * quota reflects the file immediately and never under-counts while + * optimization is still queued. Guarded by quota_status so it applies once. + * + * @return bool True when the charge was applied, false when skipped. + */ + public static function chargeOriginal(Media $media): bool + { + // A freshly saved row may not have the DB default hydrated on the model + // instance yet, so treat a null status as Pending. + $status = $media->quota_status ?? MediaQuotaStatus::Pending; + if (! $media->user_id || $status !== MediaQuotaStatus::Pending) { + return false; + } + + $bytes = (int) ($media->original_size ?? $media->size); + if ($bytes <= 0) { + return false; + } + + if (self::increaseStorageUsed($media->user_id, $bytes) === null) { + return false; + } + + $media->quota_status = MediaQuotaStatus::OriginalSize; + $media->saveQuietly(); + + return true; + } + + /** + * Correct a charged media down to its optimized on-disk size, moving it + * OriginalSize -> OptimizedSize by subtracting (original_size - size). + * + * Called from the async finalize job once media.size holds the optimized + * value. The delta is signed: if optimization somehow grew the file the + * quota is adjusted up instead. Guarded by quota_status so it applies once. + * + * @return bool True when the correction was applied, false when skipped. + */ + public static function chargeOptimized(Media $media): bool + { + if (! $media->user_id || $media->quota_status !== MediaQuotaStatus::OriginalSize) { + return false; + } + + $original = (int) ($media->original_size ?? $media->size); + $optimized = (int) $media->size; + $delta = $original - $optimized; // bytes freed by optimization (usually > 0) + + if ($delta > 0) { + self::decrementStorageUsed($media->user_id, $delta); + } elseif ($delta < 0) { + self::increaseStorageUsed($media->user_id, -$delta); + } + + $media->quota_status = MediaQuotaStatus::OptimizedSize; + $media->saveQuietly(); + + return true; + } + + /** + * Refund a deleted media's currently-reflected size to its owner's + * storage_used, moving it to Subtracted. + * + * Refunds the raw size when still OriginalSize, the optimized size when + * OptimizedSize, and nothing when Pending (never charged) or already + * Subtracted. Guarded by quota_status so it refunds once. + * + * @return bool True when a refund was applied, false when skipped. + */ + public static function subtractMedia(Media $media): bool + { + if (! $media->user_id || ! $media->quota_status->isCharged()) { + return false; + } + + $bytes = $media->quota_status === MediaQuotaStatus::OriginalSize + ? (int) ($media->original_size ?? $media->size) + : (int) $media->size; + + if ($bytes > 0) { + self::decrementStorageUsed($media->user_id, $bytes); + } + + // The media row is deleted right after this in the delete pipeline, so + // only stamp the status when the row still exists (e.g. tests). + if ($media->exists) { + $media->quota_status = MediaQuotaStatus::Subtracted; + $media->saveQuietly(); + } + + return true; + } + /** * Decrement a user's cached storage_used by the size (in bytes) of removed * media, without re-summing the whole media table. diff --git a/config/scheduledtasks.php b/config/scheduledtasks.php index 794a3418e..4e8d41d70 100644 --- a/config/scheduledtasks.php +++ b/config/scheduledtasks.php @@ -14,11 +14,12 @@ return [ /* | Account storage reconciler | - | Optional weekly scheduled task that recalculates users.storage_used from - | actual media, correcting drift on accounts that never upload or delete. - | The upload/delete/read paths already self-heal stale counters, so this is - | a background hygiene job and is disabled by default. + | Weekly scheduled task that recalculates users.storage_used from actual + | media, correcting drift. The incremental charge/refund on the + | upload/delete paths is best-effort (a finalize-job retry can over-count), + | so this reconciler is the authoritative backstop and is enabled by + | default. The upload/delete/read paths also self-heal stale counters inline. */ - 'account_storage_reconcile' => env('ACCOUNT_STORAGE_RECONCILE', false), + 'account_storage_reconcile' => env('ACCOUNT_STORAGE_RECONCILE', true), ]; diff --git a/database/migrations/2026_09_13_000000_add_storage_accounting_to_media_table.php b/database/migrations/2026_09_13_000000_add_storage_accounting_to_media_table.php new file mode 100644 index 000000000..9bf7cf62a --- /dev/null +++ b/database/migrations/2026_09_13_000000_add_storage_accounting_to_media_table.php @@ -0,0 +1,47 @@ +unsignedInteger('original_size')->nullable()->after('size'); + $table->string('quota_status', 20) + ->default(MediaQuotaStatus::Pending->value) + ->index() + ->after('original_size'); + }); + + // Existing rows were already counted at their (optimized) `size` under + // the previous scheme, so mark them optimized_size to keep the delete + // refund correct. Skipped for empty tables (fresh installs). + DB::table('media')->update(['quota_status' => MediaQuotaStatus::OptimizedSize->value]); + } + + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + $table->dropColumn(['original_size', 'quota_status']); + }); + } +}; diff --git a/tests/Feature/Media/MediaStorageChargeTest.php b/tests/Feature/Media/MediaStorageChargeTest.php new file mode 100644 index 000000000..c1acde673 --- /dev/null +++ b/tests/Feature/Media/MediaStorageChargeTest.php @@ -0,0 +1,123 @@ + true, + 'pixelfed.max_account_size' => 1000, // 1000 KB cap + ]); + + $user = User::factory()->create(); + $user->refresh(); + + // Already near the cap. + $user->storage_used = 990; + $user->storage_used_updated_at = now(); + $user->save(); + + // A ~1.4 MB raw image pushes the raw-size projection over the cap. + $file = UploadedFile::fake()->image('big.jpg', 4000, 4000)->size(1400); + + $this->actingAs($user) + ->post('/api/compose/v0/media/upload', ['file' => $file]) + ->assertStatus(403); + + // Nothing charged: the counter is unchanged and no media row persisted. + $user->refresh(); + expect((int) $user->storage_used)->toBe(990); + expect(Media::where('user_id', $user->id)->count())->toBe(0); +}); + +it('charges the raw size at upload and marks the media OriginalSize', function () { + Storage::fake(config('filesystems.default')); + config([ + 'pixelfed.enforce_account_limit' => true, + 'pixelfed.max_account_size' => 1000000, + ]); + + // Don't run the finalize jobs; assert the upload (controller) half only. + Bus::fake(); + + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 100; + $user->storage_used_updated_at = now(); + $user->save(); + + $file = UploadedFile::fake()->image('ok.jpg', 1080, 1080); + + $this->actingAs($user) + ->post('/api/compose/v0/media/upload', ['file' => $file]) + ->assertOk(); + + $media = Media::where('user_id', $user->id)->first(); + + expect($media)->not->toBeNull() + ->and((int) $media->original_size)->toBeGreaterThan(0) + ->and($media->quota_status)->toBe(MediaQuotaStatus::OriginalSize); + + // storage_used grew by the raw upload size immediately (never under-counts). + $expected = 100 + (int) ceil($media->original_size / 1000); + $user->refresh(); + expect((int) $user->storage_used)->toBe($expected); +}); + +it('corrects the quota down to the optimized size when the finalize job runs', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + // Uploaded (raw 900 KB) and charged at upload. + $media = Media::create([ + 'status_id' => null, + 'profile_id' => $user->profile->id, + 'user_id' => $user->id, + 'media_path' => 'public/m/_v2/1/final.jpeg', + 'mime' => 'image/jpeg', + 'size' => 900000, + 'original_size' => 900000, + 'quota_status' => MediaQuotaStatus::Pending, + 'order' => 1, + ]); + + UserStorageService::chargeOriginal($media); + $user->refresh(); + expect((int) $user->storage_used)->toBe(900); + + // Finalize job optimizes to 320 KB then corrects the quota. + $media->size = 320000; + $media->save(); + UserStorageService::chargeOptimized($media->fresh()); + + $user->refresh(); + expect((int) $user->storage_used)->toBe(320) + ->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OptimizedSize); +}); diff --git a/tests/Feature/MediaPipeline/MediaDeletePipelineTest.php b/tests/Feature/MediaPipeline/MediaDeletePipelineTest.php index 68dea6875..740fe600e 100644 --- a/tests/Feature/MediaPipeline/MediaDeletePipelineTest.php +++ b/tests/Feature/MediaPipeline/MediaDeletePipelineTest.php @@ -1,9 +1,11 @@ storage_used_updated_at = now(); $user->save(); - // A single orphaned media row of 500,000 bytes (~500 KB). + // A single orphaned media row of 500,000 bytes (~500 KB) that was charged + // (quota_status reflects the optimized size on the counter). $media = Media::create([ 'status_id' => null, 'profile_id' => $user->profile->id, @@ -85,6 +88,7 @@ it('decrements the owner storage_used by the deleted media size', function () { 'media_path' => 'public/m/_v2/1/orphan.jpeg', 'mime' => 'image/jpeg', 'size' => 500000, + 'quota_status' => MediaQuotaStatus::OptimizedSize, 'order' => 1, ]); @@ -118,6 +122,7 @@ it('clamps storage_used at zero when the deleted media is larger than the counte 'media_path' => 'public/m/_v2/1/big.jpeg', 'mime' => 'image/jpeg', 'size' => 500000, + 'quota_status' => MediaQuotaStatus::OptimizedSize, 'order' => 1, ]); @@ -160,3 +165,80 @@ it('does not change storage_used when deletion is skipped for attached media', f $user->refresh(); expect((int) $user->storage_used)->toBe(4242); }); + +/* +|-------------------------------------------------------------------------- +| MediaDeletePipeline refund by quota lifecycle +|-------------------------------------------------------------------------- +| +| Delete refunds whatever the media currently reflects in storage_used, per its +| quota_status: the optimized size when OptimizedSize, the raw size when +| OriginalSize, and nothing when it was never charged (Pending). This keeps the +| charge and refund symmetric across the lifecycle. +| +*/ + +it('charge then delete cancels to baseline via the quota lifecycle', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + // Uploaded (raw 900 KB) then optimized to 250 KB. + $media = Media::create([ + 'status_id' => null, + 'profile_id' => $user->profile->id, + 'user_id' => $user->id, + 'media_path' => 'public/m/_v2/1/charged.jpeg', + 'mime' => 'image/jpeg', + 'size' => 900000, + 'original_size' => 900000, + 'quota_status' => MediaQuotaStatus::Pending, + 'order' => 1, + ]); + + UserStorageService::chargeOriginal($media); + $media->size = 250000; + $media->save(); + UserStorageService::chargeOptimized($media->fresh()); + $user->refresh(); + expect((int) $user->storage_used)->toBe(250); + + (new MediaDeletePipeline($media->fresh()))->handle(); + + // Back to zero: refund of the optimized size cancels the net charge. + $user->refresh(); + expect((int) $user->storage_used)->toBe(0); +}); + +it('does not refund storage_used for a never-charged (Pending) media', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 800; + $user->storage_used_updated_at = now(); + $user->save(); + + // Uploaded but deleted before it was charged (e.g. the DM/remote race). + $media = Media::create([ + 'status_id' => null, + 'profile_id' => $user->profile->id, + 'user_id' => $user->id, + 'media_path' => 'public/m/_v2/1/pending.jpeg', + 'mime' => 'image/jpeg', + 'size' => 500000, + 'original_size' => 500000, + 'quota_status' => MediaQuotaStatus::Pending, + 'order' => 1, + ]); + + (new MediaDeletePipeline($media))->handle(); + + expect(Media::whereId($media->id)->exists())->toBeFalse(); + + // Untouched: a Pending row never contributed to the counter. + $user->refresh(); + expect((int) $user->storage_used)->toBe(800); +}); diff --git a/tests/Feature/Services/UserStorageServiceTest.php b/tests/Feature/Services/UserStorageServiceTest.php index c60ebc00b..97a56a6b8 100644 --- a/tests/Feature/Services/UserStorageServiceTest.php +++ b/tests/Feature/Services/UserStorageServiceTest.php @@ -1,5 +1,6 @@ refresh(); expect((int) $user->storage_used)->toBe(250); }); + +/* +|-------------------------------------------------------------------------- +| Media quota lifecycle (chargeOriginal / chargeOptimized / subtractMedia) +|-------------------------------------------------------------------------- +| +| The amount a media reflects in storage_used changes over its life: +| Pending -> nothing charged +| OriginalSize -> raw size charged at upload +| OptimizedSize -> corrected down to the optimized size by the finalize job +| Subtracted -> refunded on delete +| Each transition is guarded by quota_status so it applies at most once. +| +*/ + +/** + * Build a pending-quota media row with distinct raw and (eventual) optimized + * sizes. + */ +function makeQuotaMedia(User $user, int $originalBytes, ?int $optimizedBytes = null): Media +{ + return Media::create([ + 'status_id' => null, + 'profile_id' => $user->profile->id, + 'user_id' => $user->id, + 'media_path' => 'public/m/_v2/1/q'.uniqid().'.jpeg', + 'mime' => 'image/jpeg', + 'size' => $optimizedBytes ?? $originalBytes, + 'original_size' => $originalBytes, + 'quota_status' => MediaQuotaStatus::Pending, + 'order' => 1, + ]); +} + +it('chargeOriginal adds the raw size and moves Pending to OriginalSize', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 300; + $user->storage_used_updated_at = now(); + $user->save(); + + // Raw 500 KB, will later optimize to 200 KB. + $media = makeQuotaMedia($user, 500000, 200000); + + expect(UserStorageService::chargeOriginal($media))->toBeTrue(); + + $user->refresh(); + expect((int) $user->storage_used)->toBe(800) // 300 + 500 raw + ->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OriginalSize); +}); + +it('chargeOriginal is a no-op when not Pending', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 500; + $user->storage_used_updated_at = now(); + $user->save(); + + $media = makeQuotaMedia($user, 400000); + $media->quota_status = MediaQuotaStatus::OriginalSize; + $media->save(); + + expect(UserStorageService::chargeOriginal($media))->toBeFalse(); + $user->refresh(); + expect((int) $user->storage_used)->toBe(500); +}); + +it('chargeOptimized corrects OriginalSize down to the optimized size', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + // Raw 900 KB charged, optimizes to 250 KB. + $media = makeQuotaMedia($user, 900000, 250000); + + UserStorageService::chargeOriginal($media); + $user->refresh(); + expect((int) $user->storage_used)->toBe(900); + + // Correct down by (900 - 250) = 650 KB -> 250 KB remains. + expect(UserStorageService::chargeOptimized($media->fresh()))->toBeTrue(); + $user->refresh(); + expect((int) $user->storage_used)->toBe(250) + ->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::OptimizedSize); +}); + +it('chargeOptimized is a no-op unless the media is at OriginalSize', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 100; + $user->storage_used_updated_at = now(); + $user->save(); + + // Still Pending -> chargeOptimized must not run. + $media = makeQuotaMedia($user, 500000, 200000); + + expect(UserStorageService::chargeOptimized($media))->toBeFalse(); + $user->refresh(); + expect((int) $user->storage_used)->toBe(100); +}); + +it('chargeOptimized adjusts up when optimization grew the file', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + // Raw 100 KB, "optimized" ends up 150 KB (rare, e.g. format change). + $media = makeQuotaMedia($user, 100000, 150000); + + UserStorageService::chargeOriginal($media); + UserStorageService::chargeOptimized($media->fresh()); + + $user->refresh(); + expect((int) $user->storage_used)->toBe(150); +}); + +it('subtractMedia refunds the raw size for an OriginalSize media', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + // Video-like: charged raw, never optimized (stays OriginalSize). + $media = makeQuotaMedia($user, 400000); + UserStorageService::chargeOriginal($media); + $user->refresh(); + expect((int) $user->storage_used)->toBe(400); + + expect(UserStorageService::subtractMedia($media->fresh()))->toBeTrue(); + $user->refresh(); + expect((int) $user->storage_used)->toBe(0) + ->and($media->fresh()->quota_status)->toBe(MediaQuotaStatus::Subtracted); +}); + +it('subtractMedia refunds the optimized size for an OptimizedSize media', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 0; + $user->storage_used_updated_at = now(); + $user->save(); + + $media = makeQuotaMedia($user, 900000, 250000); + UserStorageService::chargeOriginal($media); + UserStorageService::chargeOptimized($media->fresh()); + $user->refresh(); + expect((int) $user->storage_used)->toBe(250); + + UserStorageService::subtractMedia($media->fresh()); + $user->refresh(); + expect((int) $user->storage_used)->toBe(0); +}); + +it('subtractMedia is a no-op for a never-charged (Pending) media', function () { + $user = User::factory()->create(); + $user->refresh(); + + $user->storage_used = 800; + $user->storage_used_updated_at = now(); + $user->save(); + + $media = makeQuotaMedia($user, 500000); + + expect(UserStorageService::subtractMedia($media))->toBeFalse(); + $user->refresh(); + expect((int) $user->storage_used)->toBe(800); +}); + +it('a full upload -> optimize -> delete lifecycle nets to zero', function () { + $user = User::factory()->create(); + $user->refresh(); + + $baseline = (int) $user->storage_used; + + $media = makeQuotaMedia($user, 900000, 250000); + + UserStorageService::chargeOriginal($media); // +900 + UserStorageService::chargeOptimized($media->fresh()); // -650 -> 250 + UserStorageService::subtractMedia($media->fresh()); // -250 -> baseline + + $user->refresh(); + expect((int) $user->storage_used)->toBe($baseline); +});