145 lines
7.6 KiB
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']);
|
|
}
|
|
}
|