fn_b/tests/Feature/LocalIllustrationsTest.php

145 lines
7.6 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Articles;
use App\Services\IllustrationPool;
use App\Services\LocalIllustrations;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Tests\TestCase;
#[RunTestsInSeparateProcesses]
#[PreserveGlobalState(false)]
class LocalIllustrationsTest extends TestCase
{
private string $dir;
private string $bytes;
private string $sha;
private string $id = '11111111-1111-4111-8111-111111111111';
private string $pub = '22222222-2222-4222-8222-222222222222';
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}