trypost/tests/Unit/Traits/HasMediaTest.php

388 lines
13 KiB
PHP
Raw Permalink Normal View History

2026-01-19 00:49:13 +00:00
<?php
declare(strict_types=1);
2026-01-19 00:49:13 +00:00
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
2026-01-19 00:49:13 +00:00
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
});
test('model can get media relationship', function () {
$workspace = Workspace::factory()->create();
expect($workspace->media())->toBeInstanceOf(MorphMany::class);
2026-01-19 00:49:13 +00:00
});
test('model can add media from uploaded file', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('logo.jpg', 100, 100);
$media = $workspace->addMedia($file, 'logo');
expect($media)->toBeInstanceOf(Media::class);
expect($media->collection)->toBe('logo');
expect($media->mime_type)->toBe('image/jpeg');
expect($media->original_filename)->toBe('logo.jpg');
Storage::assertExists($media->path);
});
test('model can get media by collection after adding', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('logo.jpg', 100, 100);
$workspace->addMedia($file, 'logo');
expect($workspace->getMedia('logo')->count())->toBe(1);
});
test('model can get first media from collection', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('logo.jpg', 100, 100);
$media = $workspace->addMedia($file, 'logo');
expect($workspace->getFirstMedia('logo')->id)->toBe($media->id);
});
test('get first media returns null when no media exists', function () {
$workspace = Workspace::factory()->create();
expect($workspace->getFirstMedia('logo'))->toBeNull();
});
test('model can get first media url', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('logo.jpg', 100, 100);
$workspace->addMedia($file, 'logo');
$url = $workspace->getFirstMediaUrl('logo');
expect($url)->not->toBeNull();
});
test('get first media url returns default when no media exists', function () {
$workspace = Workspace::factory()->create();
expect($workspace->getFirstMediaUrl('logo', 'default-url'))->toBe('default-url');
});
test('get fallback avatar url returns dicebear url', function () {
$user = User::factory()->create(['name' => 'John Doe']);
$url = $user->getFallbackAvatarUrl('John Doe');
expect($url)->toContain('api.dicebear.com');
expect($url)->toContain('John+Doe');
});
test('adding media to single collection clears existing media', function () {
$workspace = Workspace::factory()->create();
$file1 = UploadedFile::fake()->image('logo1.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('logo2.jpg', 100, 100);
$media1 = $workspace->addMedia($file1, 'logo');
$media2 = $workspace->addMedia($file2, 'logo');
expect($workspace->getMedia('logo')->count())->toBe(1);
expect($workspace->getFirstMedia('logo')->id)->toBe($media2->id);
expect(Media::find($media1->id))->toBeNull();
});
test('adding media to multiple collection does not clear existing', function () {
$workspace = Workspace::factory()->create();
2026-01-19 00:49:13 +00:00
$file1 = UploadedFile::fake()->image('image1.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('image2.jpg', 100, 100);
$workspace->addMedia($file1, 'assets');
$workspace->addMedia($file2, 'assets');
2026-01-19 00:49:13 +00:00
expect($workspace->getMedia('assets')->count())->toBe(2);
2026-01-19 00:49:13 +00:00
});
test('model can add media from file path', function () {
$workspace = Workspace::factory()->create();
$tempFile = tempnam(sys_get_temp_dir(), 'test');
refactor: centralize media size limits in config + drop Document type Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer.
2026-05-04 17:54:47 +00:00
file_put_contents($tempFile, file_get_contents(__DIR__.'/../../fixtures/1x1.png'));
2026-01-19 00:49:13 +00:00
refactor: centralize media size limits in config + drop Document type Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer.
2026-05-04 17:54:47 +00:00
$media = $workspace->addMediaFromPath($tempFile, 'uploaded.png', 'logo');
2026-01-19 00:49:13 +00:00
expect($media)->toBeInstanceOf(Media::class);
refactor: centralize media size limits in config + drop Document type Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer.
2026-05-04 17:54:47 +00:00
expect($media->original_filename)->toBe('uploaded.png');
2026-01-19 00:49:13 +00:00
Storage::assertExists($media->path);
unlink($tempFile);
});
test('model can add video media from file path via stream', function () {
$workspace = Workspace::factory()->create();
$tempFile = tempnam(sys_get_temp_dir(), 'vid');
$bytes = "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64);
file_put_contents($tempFile, $bytes);
$media = $workspace->addMediaFromPath($tempFile, 'clip.mp4', 'assets');
expect($media->type->value)->toBe('video');
expect($media->size)->toBe(strlen($bytes));
expect($media->mime_type)->toBe('video/mp4');
Storage::assertExists($media->path);
unlink($tempFile);
});
2026-01-19 00:49:13 +00:00
test('model can clear media collection', function () {
$workspace = Workspace::factory()->create();
$file1 = UploadedFile::fake()->image('logo1.jpg', 100, 100);
$file2 = UploadedFile::fake()->image('logo2.jpg', 100, 100);
$file3 = UploadedFile::fake()->image('logo3.jpg', 100, 100);
// Add to 'logo' collection (single, will only keep last one)
$workspace->addMedia($file1, 'logo');
// Need to use a 'multiple' collection model
$ws = Workspace::factory()->create();
$ws->addMedia($file2, 'assets');
$ws->addMedia($file3, 'assets');
2026-01-19 00:49:13 +00:00
expect($ws->getMedia('assets')->count())->toBe(2);
2026-01-19 00:49:13 +00:00
$ws->clearMediaCollection('assets');
2026-01-19 00:49:13 +00:00
expect($ws->getMedia('assets')->count())->toBe(0);
2026-01-19 00:49:13 +00:00
});
test('is single media collection returns true for single collections', function () {
$workspace = Workspace::factory()->create();
expect($workspace->isSingleMediaCollection('logo'))->toBeTrue();
});
test('is single media collection returns false for multiple collections', function () {
$workspace = Workspace::factory()->create();
2026-01-19 00:49:13 +00:00
expect($workspace->isSingleMediaCollection('assets'))->toBeFalse();
2026-01-19 00:49:13 +00:00
});
test('is single media collection returns false for undefined collections', function () {
$workspace = Workspace::factory()->create();
expect($workspace->isSingleMediaCollection('undefined'))->toBeFalse();
});
test('add media detects video type', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->create('video.mp4', 1000, 'video/mp4');
$media = $workspace->addMedia($file, 'logo');
expect($media->type->value)->toBe('video');
});
test('add media detects document type for a pdf', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->create('deck.pdf', 1000, 'application/pdf');
$media = $workspace->addMedia($file, 'logo');
expect($media->type->value)->toBe('document');
});
refactor: centralize media size limits in config + drop Document type Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer.
2026-05-04 17:54:47 +00:00
test('add media throws on unsupported MIME type', function () {
2026-01-19 00:49:13 +00:00
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->create('archive.zip', 1000, 'application/zip');
2026-01-19 00:49:13 +00:00
refactor: centralize media size limits in config + drop Document type Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer.
2026-05-04 17:54:47 +00:00
expect(fn () => $workspace->addMedia($file, 'logo'))
->toThrow(InvalidArgumentException::class);
2026-01-19 00:49:13 +00:00
});
test('add media includes custom meta', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('logo.jpg', 100, 100);
$media = $workspace->addMedia($file, 'logo', ['custom_key' => 'custom_value']);
expect($media->meta)->toHaveKey('custom_key');
expect($media->meta['custom_key'])->toBe('custom_value');
});
test('add media from path detects image dimensions', function () {
$workspace = Workspace::factory()->create();
// Create a real image file
$tempFile = tempnam(sys_get_temp_dir(), 'test');
$image = imagecreatetruecolor(200, 150);
imagejpeg($image, $tempFile);
imagedestroy($image);
$media = $workspace->addMediaFromPath($tempFile, 'test.jpg', 'logo');
expect($media->meta)->toHaveKey('width');
expect($media->meta)->toHaveKey('height');
expect($media->meta['width'])->toBe(200);
expect($media->meta['height'])->toBe(150);
unlink($tempFile);
});
test('user has_photo returns false when no media', function () {
2026-01-19 00:49:13 +00:00
$user = User::factory()->create(['name' => 'Test User']);
expect($user->has_photo)->toBeFalse();
expect($user->photo_url)->toBeNull();
2026-01-19 00:49:13 +00:00
});
test('user has_photo returns true when avatar exists', function () {
2026-01-19 00:49:13 +00:00
$user = User::factory()->create(['name' => 'Test User']);
$file = UploadedFile::fake()->image('avatar.jpg', 100, 100);
$user->addMedia($file, 'avatar');
2026-01-19 00:49:13 +00:00
$user->refresh();
expect($user->has_photo)->toBeTrue();
expect($user->photo_url)->not->toBeNull();
2026-01-19 00:49:13 +00:00
});
test('isVideo detects mp4 files', function () {
$media = new Media(['path' => 'medias/test.mp4']);
expect($media->isVideo())->toBeTrue();
expect($media->isImage())->toBeFalse();
});
test('isVideo detects mov files', function () {
$media = new Media(['path' => 'medias/test.mov']);
expect($media->isVideo())->toBeTrue();
});
test('isImage detects jpg files', function () {
$media = new Media(['path' => 'medias/test.jpg']);
expect($media->isImage())->toBeTrue();
expect($media->isVideo())->toBeFalse();
});
test('isImage detects png files', function () {
$media = new Media(['path' => 'medias/test.png']);
expect($media->isImage())->toBeTrue();
});
test('isVideo returns false for image files', function () {
$media = new Media(['path' => 'medias/test.webp']);
expect($media->isVideo())->toBeFalse();
expect($media->isImage())->toBeTrue();
});
test('mp4 with quicktime mime still detected as video by path', function () {
$media = new Media(['path' => 'medias/test.mp4', 'mime_type' => 'video/quicktime']);
expect($media->isVideo())->toBeTrue();
});
test('PNG upload is converted to JPEG at upload time', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.png', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and($media->path)->toEndWith('.jpg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
$bytes = Storage::get($media->path);
expect(substr($bytes, 0, 3))->toBe("\xFF\xD8\xFF"); // JPEG SOI marker
});
test('JPEG upload stays as JPEG (no-op)', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.jpg', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
});
test('GIF upload preserves GIF format for animation', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('anim.gif', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/gif')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('gif');
});
test('WebP upload is converted to JPEG', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.webp', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
});
fix: sanitize invalid UTF-8 bytes in uploaded filenames (#265) * fix: sanitize invalid UTF-8 bytes in uploaded filenames A client-supplied filename containing a raw non-UTF-8 byte (e.g. 0x97, a Windows-1252 em dash) crashed the media insert with an uncaught QueryException: Postgres rejects invalid UTF-8 byte sequences outright under UTF8 encoding. Centralized a sanitizeOriginalFilename() helper in HasMedia and applied it to all three insert paths that store original_filename: addMedia(), addMediaFromPath(), and addMediaFromStoredPath() (the multipart cloud-upload registration path) — so every upload entry point is covered, not just the one that happened to crash in production. Fixes Nightwatch issue #24. * refactor: use mb_scrub() instead of the mb_convert_encoding same-encoding trick mb_scrub() (PHP 8.1+) is the purpose-built function for scrubbing invalid byte sequences — same behavior, clearer intent than the convert-to-same-encoding workaround it replaces. * test: cover addMediaFromStoredPath (previously untested, including sanitize fix) addMediaFromStoredPath — the multipart cloud-upload registration path — had zero test coverage before this PR, including for the invalid UTF-8 filename fix applied to it. Added a basic happy-path test plus the sanitize regression test, matching the coverage already added for addMedia() and addMediaFromPath(). Verified the regression test actually catches the bug: reverted the sanitize call for this one method locally, confirmed the test fails with the exact Nightwatch #24 QueryException, then restored the fix.
2026-08-09 17:29:29 +00:00
test('model can add media from stored path', function () {
$workspace = Workspace::factory()->create();
$content = file_get_contents(__DIR__.'/../../fixtures/1x1.png');
Storage::put('medias/existing.png', $content);
$media = $workspace->addMediaFromStoredPath('medias/existing.png', 'existing.png', 'image/png', strlen($content), 'assets');
expect($media)->toBeInstanceOf(Media::class);
expect($media->original_filename)->toBe('existing.png');
expect($media->path)->toBe('medias/existing.png');
expect($media->mime_type)->toBe('image/png');
expect($media->size)->toBe(strlen($content));
});
test('add media from stored path sanitizes invalid UTF-8 bytes in the original filename', function () {
$workspace = Workspace::factory()->create();
$content = file_get_contents(__DIR__.'/../../fixtures/1x1.png');
Storage::put('medias/existing.png', $content);
$invalidName = "earnings \x97 report.png";
$media = $workspace->addMediaFromStoredPath('medias/existing.png', $invalidName, 'image/png', strlen($content), 'assets');
expect(mb_check_encoding($media->original_filename, 'UTF-8'))->toBeTrue();
expect($media->original_filename)->toBe('earnings ? report.png');
});
test('add media sanitizes invalid UTF-8 bytes in the original filename', function () {
$workspace = Workspace::factory()->create();
$invalidName = "earnings \x97 report.jpg";
$file = UploadedFile::fake()->image($invalidName, 100, 100);
$media = $workspace->addMedia($file, 'assets');
expect(mb_check_encoding($media->original_filename, 'UTF-8'))->toBeTrue();
expect($media->original_filename)->toBe('earnings ? report.jpg');
});
test('add media from path sanitizes invalid UTF-8 bytes in the original filename', function () {
$workspace = Workspace::factory()->create();
$tempFile = tempnam(sys_get_temp_dir(), 'test');
file_put_contents($tempFile, file_get_contents(__DIR__.'/../../fixtures/1x1.png'));
$invalidName = "earnings \x97 report.png";
$media = $workspace->addMediaFromPath($tempFile, $invalidName, 'assets');
expect(mb_check_encoding($media->original_filename, 'UTF-8'))->toBeTrue();
expect($media->original_filename)->toBe('earnings ? report.png');
unlink($tempFile);
});
test('client meta is merged into media meta', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.jpg', 640, 480);
$media = $workspace->addMedia($file, 'assets', ['duration' => 12.5]);
expect($media->meta)->toHaveKey('duration', 12.5)
->and($media->meta)->toHaveKey('width')
->and($media->meta)->toHaveKey('height');
});