app['env'] = 'production'; $this->dir = sys_get_temp_dir().'/fna-local-images-'.bin2hex(random_bytes(8)); mkdir($this->dir, 0700, true); file_put_contents($this->dir.'/credential.json', json_encode(['project' => 'fna-v3', 'base_url' => 'https://resource-v2.gemcook.net', 'token' => 'rsv2_'.str_repeat('a', 64)])); config(['illustrations.enabled' => true, 'illustrations.local_enabled' => true, 'illustrations.base_url' => 'https://resource-v2.gemcook.net', 'illustrations.credential_file' => $this->dir.'/credential.json', 'illustrations.local_directory' => $this->dir.'/public', 'illustrations.local_manifest' => $this->dir.'/private/manifest.json']); $this->bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jp1sAAAAASUVORK5CYII='); $this->sha = hash('sha256', $this->bytes); Http::preventStrayRequests(); } protected function tearDown(): void { File::deleteDirectory($this->dir); parent::tearDown(); } private function image(?string $id = null): array { return ['asset_id' => $id ?? $this->id, 'name' => 'cat.png', 'url' => 'https://resource-v2.gemcook.net/media/'.$this->pub]; } private function fakeImages(bool $corrupt = false): void { Http::swap(new \Illuminate\Http\Client\Factory()); Http::preventStrayRequests(); Http::fake([ 'https://resource-v2.gemcook.net/api/v2/assets/*' => Http::response(['data' => ['sha256' => $this->sha, 'mime' => 'image/png', 'bytes' => strlen($this->bytes)]]), 'https://resource-v2.gemcook.net/media/*' => Http::response($corrupt ? 'bad image' : $this->bytes, 200, ['Content-Type' => 'image/png']), ]); } public function test_content_hash_deduplicates_assets_and_page_reads_make_no_http_requests(): void { $this->fakeImages(); $local = app(LocalIllustrations::class); $url = $local->storeSelection('freenode', 'fn1', $this->image()); $local->storeSelection('freenode', 'fn2', $this->image('33333333-3333-4333-8333-333333333333')); $this->assertSame('/images/illustrations/'.$this->sha.'.png', $url); $this->assertCount(1, glob($this->dir.'/public/*')); $this->assertSame($this->bytes, file_get_contents($this->dir.'/public/'.$this->sha.'.png')); Http::assertSentCount(3); // Two metadata reads, one content download. Http::fake(); config(['illustrations.enabled' => false]); // Frontend survives Resource being disabled. for ($i = 0; $i < 5; $i++) { $a = new Articles(['iCategoryId' => 1, 'sSlug' => 'fn1', 'sThumb' => 'https://old.invalid/image']); $this->assertSame(url($url), $a->sThumb); $this->assertSame($a->sThumb, $a->toArray()['sThumb']); $this->assertSame($a->sThumb, json_decode($a->toJson(), true)['sThumb']); } Http::assertNothingSent(); } public function test_corrupt_download_preserves_old_mapping_and_publishes_no_partial_file(): void { $this->fakeImages();$local = app(LocalIllustrations::class); $local->storeSelection('freenode', 'fn1', $this->image()); $before = file_get_contents($this->dir.'/private/manifest.json'); $this->fakeImages(true); unlink($this->dir.'/public/'.$this->sha.'.png'); try { $local->storeSelection('freenode', 'fn2', $this->image());$this->fail('Expected invalid download rejection'); } catch (\RuntimeException $e) { $this->assertStringContainsString('integrity', $e->getMessage()); } $this->assertSame($before, file_get_contents($this->dir.'/private/manifest.json')); $this->assertSame([], glob($this->dir.'/public/*')); $this->assertSame([], glob($this->dir.'/private/.image-*')); } public function test_off_origin_publication_is_rejected_without_any_network_request(): void { $image = $this->image();$image['url'] = 'https://example.com/media/'.$this->pub; try { app(LocalIllustrations::class)->storeSelection('freenode', 'fn1', $image);$this->fail('Expected source rejection'); } catch (\RuntimeException $e) { $this->assertSame('Unregistered illustration source', $e->getMessage()); } Http::assertNothingSent(); } public function test_new_article_assignment_downloads_before_returning_local_url_and_retry_reuses_it(): void { $this->fakeImages(); Http::fake(['https://resource-v2.gemcook.net/api/v2/pools/freenode/selections' => Http::response(['data' => ['selections' => ['fn1' => $this->image()]]])]); $first = IllustrationPool::forArticle('freenode', 'fn1'); $this->assertSame(url('/images/illustrations/'.$this->sha.'.png'), $first); $this->assertSame($first, IllustrationPool::forArticle('freenode', 'fn1')); $this->assertSame($first, url(app(LocalIllustrations::class)->thumbnail('freenode', 'fn1'))); Http::assertSentCount(4); // Two assignment requests; content downloaded once. } public function test_sync_stops_showing_withdrawn_mapping_without_automatically_deleting_shared_file(): void { $this->fakeImages();$local = app(LocalIllustrations::class);$image = $this->image(); $local->storeSelection('freenode', 'fn1', $image);$local->storeSelection('freenode', 'fn2', $image); $local->synchronize(['freenode' => ['items' => [$image], 'selections' => ['fn2' => $image], 'unavailable' => ['fn1' => 'publication_revoked']]]); $this->assertNull($local->thumbnail('freenode', 'fn1')); $this->assertNotNull($local->thumbnail('freenode', 'fn2')); $this->assertFileExists($this->dir.'/public/'.$this->sha.'.png'); } public function test_missing_manifest_never_fetches_resource_or_returns_remote_fallback(): void { $this->assertNull(IllustrationPool::thumbnail(['iCategoryId' => 1, 'sSlug' => 'fn1'], 'https://old.invalid/image')); $this->assertSame('/tutorial.png', IllustrationPool::thumbnail(['iCategoryId' => 3, 'sSlug' => 'guide'], '/tutorial.png')); Http::assertNothingSent(); } public function test_sync_does_not_overwrite_concurrent_article_assignments(): void { $this->fakeImages();$local = app(LocalIllustrations::class);$old = $this->image(); $local->storeSelection('freenode', 'fn1', $old); $baseline = $local->selectionBaseline(); $new = $this->image('33333333-3333-4333-8333-333333333333'); $local->storeSelection('freenode', 'fn1', $new); $local->storeSelection('freenode', 'fn2', $new); $local->synchronize(['freenode' => ['items' => [$old], 'selections' => ['fn1' => $old], 'unavailable' => []]], $baseline); $state = json_decode(file_get_contents($this->dir.'/private/manifest.json'), true); $this->assertSame($new['asset_id'], $state['selections']['freenode']['fn1']); $this->assertSame($new['asset_id'], $state['selections']['freenode']['fn2']); } }