diff --git a/app/Services/LocalIllustrations.php b/app/Services/LocalIllustrations.php index 8fe34c40..a461b8d5 100644 --- a/app/Services/LocalIllustrations.php +++ b/app/Services/LocalIllustrations.php @@ -160,17 +160,20 @@ public function synchronize(array $snapshots, ?array $baseline = null): array $state['assets'][$id] = $this->copy($image, $state['assets']); $available[$id] = true; } - $selections = []; + // Published FNA copies are independent of the source lifecycle. + // A deleted source reference must not silently remove a local assignment. + $selections = $state['selections'][$pool] ?? []; foreach ($snapshot['selections'] as $slug => $image) $selections[$slug] = $image['asset_id']; foreach ($snapshot['unavailable'] ?? [] as $slug => $reason) { $id = $state['selections'][$pool][$slug] ?? null; $old = $id ? ($state['assets'][$id] ?? null) : null; $local = $old ? config('illustrations.local_directory').'/'.basename($old['url']) : null; - // A healthy independent display copy survives physical loss at the source. - if (in_array($reason, ['file_missing', 'file_corrupt', 'file_symlink']) && $local && is_file($local) && ! is_link($local) && hash_equals($old['sha256'], hash_file('sha256', $local))) { + // Source deletion, disabling or withdrawal never revokes an FNA copy. + if ($local && is_file($local) && ! is_link($local) && hash_equals($old['sha256'], hash_file('sha256', $local))) { $selections[$slug] = $id; } else { - $selections[$slug] = null; + // Report missing/corrupt local bytes without destroying recovery metadata. + $selections[$slug] = $id; $pending[$pool.':'.$slug] = $reason; } } diff --git a/tests/Feature/LocalIllustrationsTest.php b/tests/Feature/LocalIllustrationsTest.php index 455c8a27..89a944a2 100644 --- a/tests/Feature/LocalIllustrationsTest.php +++ b/tests/Feature/LocalIllustrationsTest.php @@ -111,14 +111,35 @@ public function test_new_article_assignment_downloads_before_returning_local_url Http::assertSentCount(4); // Two assignment requests; content downloaded once. } - public function test_sync_stops_showing_withdrawn_mapping_without_automatically_deleting_shared_file(): void + public function test_source_withdrawal_disabling_and_deletion_preserve_independent_local_copies(): 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')); + Http::fake(); + foreach (['publication_revoked', 'asset_disabled', 'asset_inactive', 'access_revoked', 'file_missing', 'file_corrupt', 'file_symlink'] as $reason) { + $result = $local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => ['fn1' => $reason]]]); + $this->assertSame([], $result['pending']); + $this->assertSame('/images/illustrations/'.$this->sha.'.png', $local->thumbnail('freenode', 'fn1')); + } + // A deleted upstream selection is absent even from the unavailable map. + $local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => []]]); + $this->assertNotNull($local->thumbnail('freenode', 'fn1')); $this->assertNotNull($local->thumbnail('freenode', 'fn2')); $this->assertFileExists($this->dir.'/public/'.$this->sha.'.png'); + Http::assertNothingSent(); + } + + public function test_sync_reports_missing_local_file_without_erasing_its_assignment(): void + { + $this->fakeImages();$local = app(LocalIllustrations::class); + $local->storeSelection('freenode', 'fn1', $this->image()); + unlink($this->dir.'/public/'.$this->sha.'.png'); + Http::fake(); + $result = $local->synchronize(['freenode' => ['items' => [], 'selections' => [], 'unavailable' => ['fn1' => 'asset_disabled']]]); + $this->assertSame(['freenode:fn1' => 'asset_disabled'], $result['pending']); + $state = json_decode(file_get_contents($this->dir.'/private/manifest.json'), true); + $this->assertSame($this->id, $state['selections']['freenode']['fn1']); + Http::assertNothingSent(); } public function test_missing_manifest_never_fetches_resource_or_returns_remote_fallback(): void