diff --git a/src/Services/WarehouseService.php b/src/Services/WarehouseService.php index 2bb520a..2ac6c52 100644 --- a/src/Services/WarehouseService.php +++ b/src/Services/WarehouseService.php @@ -71,8 +71,31 @@ class WarehouseService /** * Search for a static asset, trying preferred extensions. + * + * On a miss the realpath/stat cache is cleared and the lookup retried once: + * an asset can be written by another process (an image-generation command, + * a queue worker) *after* this PHP process last stat()'d the path, so a + * stale negative cache entry would otherwise 404 a file that exists on disk + * until the process restarts. Only misses pay this cost — hits return on the + * first attempt and never reach the retry. */ protected static function searchAssetPath(string $path): ?File + { + $found = static::resolveAssetPath($path); + if ($found) { + return $found; + } + + clearstatcache(true); + + return static::resolveAssetPath($path); + } + + /** + * Resolve an asset path to a (non-persisted) File: exact path first, then + * the configured preferred extensions when the path carries none. + */ + protected static function resolveAssetPath(string $path): ?File { $disk = config('files.disk', 'local'); $extensions = config('files.optimization.preferred_extensions', ['svg', 'webp', 'png', 'jpg', 'jpeg']); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..7e1fc41 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,66 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Needed by anything exercising encrypt()/decrypt() (e.g. encrypted + // warehouse IDs). Harmless for the rest. + $app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + + // The suite loads the shipped migrations explicitly (see + // defineDatabaseMigrations); disable the provider's auto-load so the + // same migration files aren't registered from two paths. + $app['config']->set('files.run_migrations', false); + } + + protected function defineDatabaseMigrations(): void + { + // Host-app tables (users, articles) used as morph targets in tests. + $this->loadMigrationsFrom(__DIR__ . '/../workbench/database/migrations'); + + // The package's actual shipped schema — the single source of truth. + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake('local'); + } +} diff --git a/tests/Unit/ChunkUploadServiceTest.php b/tests/Unit/ChunkUploadServiceTest.php index 9d10afe..c6b90b8 100644 --- a/tests/Unit/ChunkUploadServiceTest.php +++ b/tests/Unit/ChunkUploadServiceTest.php @@ -2,43 +2,14 @@ namespace Blax\Files\Tests\Unit; -use Blax\Files\FilesServiceProvider; use Blax\Files\Models\File; use Blax\Files\Services\ChunkUploadService; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Blax\Files\Tests\TestCase; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; -use Orchestra\Testbench\TestCase; class ChunkUploadServiceTest extends TestCase { - use RefreshDatabase; - - protected function getPackageProviders($app): array - { - return [FilesServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function defineDatabaseMigrations(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); - } - - protected function setUp(): void - { - parent::setUp(); - Storage::fake('local'); - } // ─── initialize ──────────────────────────────────────────────── diff --git a/tests/Unit/FilableModelTest.php b/tests/Unit/FilableModelTest.php index 6fa393e..b364e15 100644 --- a/tests/Unit/FilableModelTest.php +++ b/tests/Unit/FilableModelTest.php @@ -3,42 +3,13 @@ namespace Blax\Files\Tests\Unit; use Blax\Files\Enums\FileLinkType; -use Blax\Files\FilesServiceProvider; use Blax\Files\Models\Filable; use Blax\Files\Models\File; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Blax\Files\Tests\TestCase; use Illuminate\Support\Facades\Storage; -use Orchestra\Testbench\TestCase; class FilableModelTest extends TestCase { - use RefreshDatabase; - - protected function getPackageProviders($app): array - { - return [FilesServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function defineDatabaseMigrations(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); - } - - protected function setUp(): void - { - parent::setUp(); - Storage::fake('local'); - } // ─── scopeAs ─────────────────────────────────────────────────── @@ -188,36 +159,26 @@ class FilableModelTest extends TestCase public function test_default_ordering_by_order_column() { - $file = File::create([ - 'name' => 'test', - 'extension' => 'png', - 'type' => 'image/png', - 'disk' => 'local', - ]); + // Three DISTINCT files attached to the same host as 'gallery'. They must + // be distinct files so they don't collide on the real + // (file_id, filable_type, filable_id, as) unique constraint; inserted + // out of order to prove the global `ordered` scope sorts ascending. + foreach ([3, 1, 2] as $order) { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); - Filable::create([ - 'file_id' => $file->id, - 'filable_id' => 1, - 'filable_type' => 'App\Models\User', - 'as' => 'gallery', - 'order' => 3, - ]); - - Filable::create([ - 'file_id' => $file->id, - 'filable_id' => 1, - 'filable_type' => 'App\Models\User', - 'as' => 'gallery', - 'order' => 1, - ]); - - Filable::create([ - 'file_id' => $file->id, - 'filable_id' => 1, - 'filable_type' => 'App\Models\User', - 'as' => 'gallery', - 'order' => 2, - ]); + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => $order, + ]); + } $filables = Filable::where('filable_type', 'App\Models\User')->get(); $this->assertEquals(1, $filables[0]->order); diff --git a/tests/Unit/FileModelTest.php b/tests/Unit/FileModelTest.php index a2293ab..38ff18c 100644 --- a/tests/Unit/FileModelTest.php +++ b/tests/Unit/FileModelTest.php @@ -3,41 +3,12 @@ namespace Blax\Files\Tests\Unit; use Blax\Files\Enums\FileLinkType; -use Blax\Files\FilesServiceProvider; use Blax\Files\Models\File; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Blax\Files\Tests\TestCase; use Illuminate\Support\Facades\Storage; -use Orchestra\Testbench\TestCase; class FileModelTest extends TestCase { - use RefreshDatabase; - - protected function getPackageProviders($app): array - { - return [FilesServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function defineDatabaseMigrations(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); - } - - protected function setUp(): void - { - parent::setUp(); - Storage::fake('local'); - } // ─── Creation & UUID ─────────────────────────────────────────── diff --git a/tests/Unit/HasFilesTest.php b/tests/Unit/HasFilesTest.php index 5bfbd3f..b9a5291 100644 --- a/tests/Unit/HasFilesTest.php +++ b/tests/Unit/HasFilesTest.php @@ -3,44 +3,15 @@ namespace Blax\Files\Tests\Unit; use Blax\Files\Enums\FileLinkType; -use Blax\Files\FilesServiceProvider; use Blax\Files\Models\Filable; use Blax\Files\Models\File; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Blax\Files\Tests\TestCase; use Illuminate\Support\Facades\Storage; -use Orchestra\Testbench\TestCase; use Workbench\App\Models\Article; use Workbench\App\Models\User; class HasFilesTest extends TestCase { - use RefreshDatabase; - - protected function getPackageProviders($app): array - { - return [FilesServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - protected function defineDatabaseMigrations(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); - } - - protected function setUp(): void - { - parent::setUp(); - Storage::fake('local'); - } // ─── files() relationship ────────────────────────────────────── diff --git a/tests/Unit/WarehouseServiceTest.php b/tests/Unit/WarehouseServiceTest.php index 7e4325d..b53872f 100644 --- a/tests/Unit/WarehouseServiceTest.php +++ b/tests/Unit/WarehouseServiceTest.php @@ -2,43 +2,13 @@ namespace Blax\Files\Tests\Unit; -use Blax\Files\FilesServiceProvider; use Blax\Files\Models\File; use Blax\Files\Services\WarehouseService; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Blax\Files\Tests\TestCase; use Illuminate\Support\Facades\Storage; -use Orchestra\Testbench\TestCase; class WarehouseServiceTest extends TestCase { - use RefreshDatabase; - - protected function getPackageProviders($app): array - { - return [FilesServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - $app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32))); - } - - protected function defineDatabaseMigrations(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); - } - - protected function setUp(): void - { - parent::setUp(); - Storage::fake('local'); - } // ─── searchFile — UUID lookup ────────────────────────────────── @@ -140,6 +110,17 @@ class WarehouseServiceTest extends TestCase $this->assertEquals('svg', $result->extension); } + public function test_search_returns_null_when_asset_genuinely_missing() + { + // Exercises the clearstatcache-and-retry branch in searchAssetPath: + // both resolution attempts miss, so the method must cleanly return null + // (no loop, no error) and let searchFile fall through to storage lookup. + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'images/does-not-exist'); + + $this->assertNull($result); + } + // ─── searchFile — storage path ───────────────────────────────── public function test_search_finds_by_storage_path()