diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 6a77d450597..3a846b445b1 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -10,6 +10,7 @@ use Statamic\Contracts\Globals\Variables; use Statamic\Contracts\Structures\Nav; use Statamic\Contracts\Structures\NavTree; +use Statamic\Facades; use Statamic\Facades\Antlers; use Statamic\Facades\Site; use Statamic\Facades\URL; @@ -270,6 +271,8 @@ protected function getCollectionTreeUrls($tree) { $rules = $this->parseInvalidationRules(Arr::get($this->rules, "collections.{$tree->collection()->handle()}.urls", [])); + $urls = $this->getMovedEntryUrls($tree); + $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); $prefixedRelativeUrls = $rules @@ -278,11 +281,24 @@ protected function getCollectionTreeUrls($tree) ->all(); return [ + ...$urls, ...$absoluteUrls, ...$prefixedRelativeUrls, ]; } + private function getMovedEntryUrls($tree) + { + return collect($tree->diff()->ancestryChanged()) + ->map(fn ($id) => Facades\Entry::find($id)) + ->filter() + ->reject(fn ($entry) => $entry->isRedirect()) + ->map->absoluteUrl() + ->filter() + ->values() + ->all(); + } + private function parseInvalidationRules(array $rules, array $context = []): IlluminateCollection { return collect($rules) diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index c13315a7892..b3c5d014861 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -13,12 +13,14 @@ use Statamic\Contracts\Structures\Nav; use Statamic\Contracts\Taxonomies\Taxonomy; use Statamic\Contracts\Taxonomies\Term; +use Statamic\Facades\Entry as EntryFacade; use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\Globals\Variables; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\DefaultInvalidator as Invalidator; use Statamic\Structures\CollectionTree; +use Statamic\Structures\CollectionTreeDiff; use Statamic\Structures\NavTree; use Statamic\Structures\Structure; use Statamic\Taxonomies\LocalizedTerm; @@ -224,6 +226,7 @@ public function collection_urls_can_be_invalidated_by_a_tree() $m->shouldReceive('structure')->andReturn($structure); $m->shouldReceive('collection')->andReturn($collection); $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('diff')->andReturn(new CollectionTreeDiff); }); $invalidator = new Invalidator($cacher, [ @@ -273,6 +276,7 @@ public function collection_urls_can_be_invalidated_by_a_tree_in_a_multisite() $m->shouldReceive('structure')->andReturn($structure); $m->shouldReceive('collection')->andReturn($collection); $m->shouldReceive('site')->andReturn(Site::get('fr')); + $m->shouldReceive('diff')->andReturn(new CollectionTreeDiff); }); $invalidator = new Invalidator($cacher, [ @@ -293,6 +297,56 @@ public function collection_urls_can_be_invalidated_by_a_tree_in_a_multisite() $this->assertNull($invalidator->invalidate($tree)); } + #[Test] + public function moved_entry_urls_can_be_invalidated_by_a_tree() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/parent/child', + 'http://localhost/blog/one', + ])->once(); + }); + + $movedEntry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/parent/child'); + }); + + $redirectEntry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(true); + }); + + EntryFacade::shouldReceive('find')->with('child')->andReturn($movedEntry); + EntryFacade::shouldReceive('find')->with('redirect')->andReturn($redirectEntry); + EntryFacade::shouldReceive('find')->with('missing')->andReturnNull(); + + $diff = tap(Mockery::mock(CollectionTreeDiff::class), function ($m) { + $m->shouldReceive('ancestryChanged')->andReturn(['child', 'redirect', 'missing']); + }); + + $collection = tap(Mockery::mock(Collection::class), function ($m) { + $m->shouldReceive('handle')->andReturn('blog'); + }); + + $tree = tap(Mockery::mock(CollectionTree::class), function ($m) use ($collection, $diff) { + $m->shouldReceive('collection')->andReturn($collection); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('diff')->andReturn($diff); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($tree)); + } + #[Test] public function collection_urls_can_be_invalidated_by_an_entry() { diff --git a/tests/StaticCaching/HalfMeasureStaticCachingTest.php b/tests/StaticCaching/HalfMeasureStaticCachingTest.php index 4e32a408410..48fa771f88a 100644 --- a/tests/StaticCaching/HalfMeasureStaticCachingTest.php +++ b/tests/StaticCaching/HalfMeasureStaticCachingTest.php @@ -7,6 +7,7 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StaticWarmJob; +use Statamic\Facades\Collection; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\Replacer; use Symfony\Component\HttpFoundation\Response; @@ -295,6 +296,31 @@ public function invalidating_a_cached_404_lets_new_content_be_served() $this->get('/about')->assertOk()->assertSee('The About Page'); } + #[Test] + public function moving_an_entry_in_the_tree_invalidates_the_cached_404_at_its_new_url() + { + \Illuminate\Support\Facades\Cache::flush(); + + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ title }}'); + $this->viewShouldReturnRaw('errors.404', '404 not found'); + + $collection = tap(Collection::make('pages')->routes('{parent_uri}/{slug}')->template('default'))->save(); + $this->createPage('parent', ['with' => ['title' => 'The Parent Page']]); + $this->createPage('child', ['with' => ['title' => 'The Child Page']]); + $collection->structureContents(['root' => false])->save(); + $collection->structure()->makeTree('en', [['entry' => 'parent'], ['entry' => 'child']])->save(); + + // Both pages are top level, so this URL 404s and the 404 gets cached. + $this->get('/parent/child')->assertNotFound(); + + // Moving the child page below the parent page makes the URL valid... + $collection->structure()->in('en')->tree([['entry' => 'parent', 'children' => [['entry' => 'child']]]])->save(); + + // ...so the page is served instead of the stale 404. + $this->get('/parent/child')->assertOk()->assertSee('The Child Page'); + } + #[Test] public function wildcard_refresh_invalidates_cached_404s_instead_of_warming_them() {