2026-10-14 17:32:12 +02:00
< ? php
declare ( strict_types = 1 );
namespace Uwil\SitemapGenerator\Tests\Integration ;
use Illuminate\Config\Repository as ConfigRepository ;
use Illuminate\Database\Schema\Blueprint ;
use Illuminate\Support\Facades\Route ;
use Illuminate\Support\Facades\Schema ;
use Spatie\Sitemap\Sitemap ;
use Spatie\Sitemap\Tags\Url ;
use Illuminate\Database\Eloquent\Builder ;
use Uwil\SitemapGenerator\Config\SitemapConfig ;
use Uwil\SitemapGenerator\Exceptions\SitemapException ;
use Uwil\SitemapGenerator\Localizers\DefaultRouteLocalizer ;
use Uwil\SitemapGenerator\SitemapGenerator ;
use Uwil\SitemapGenerator\SitemapHelper ;
use Uwil\SitemapGenerator\Tests\Fixtures\Article ;
use Uwil\SitemapGenerator\Tests\Fixtures\Product ;
use Uwil\SitemapGenerator\Tests\Fixtures\Range ;
use Uwil\SitemapGenerator\Tests\Unit\Config\ModelClass ;
use Webmozart\Assert\Assert ;
covers ([ SitemapGenerator :: class , SitemapHelper :: class , DefaultRouteLocalizer :: class ]);
describe ( 'SitemapGenerator with DefaultRouteLocalizer' , function () {
afterEach ( function () {
Schema :: dropIfExists ( 'model_classes' );
Schema :: dropIfExists ( 'products' );
Schema :: dropIfExists ( 'ranges' );
Schema :: dropIfExists ( 'articles' );
});
it ( 'can be instantiated and resolved from container' , function () {
// GIVEN
$abstract = SitemapGenerator :: class ;
// WHEN
$generator = app ( $abstract );
// THEN
expect ( $generator ) -> toBeInstanceOf ( SitemapGenerator :: class );
});
it ( 'generates an empty sitemap when no pages or cruds are configured' , function () {
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
// GIVEN
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator ( $config , new SitemapHelper ( $config , new DefaultRouteLocalizer ()));
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap ) -> toBeInstanceOf ( Sitemap :: class )
-> and ( $sitemap -> getTags ()) -> toBeEmpty ();
});
it ( 'generates a sitemap with single pages in a single locale' , function () {
// GIVEN
Route :: name ( 'about' ) -> get ( '{locale}/about' , fn () => 'About' );
Route :: name ( 'contact' ) -> get ( '{locale}/contact' , fn () => 'Contact' );
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [ 'about' , 'contact' ],
'supported_locales' => [ 'fr' ],
'cruds' => [],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator ( $config , new SitemapHelper ( $config , new DefaultRouteLocalizer ()));
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap ) -> toBeInstanceOf ( Sitemap :: class )
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/about' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/contact' )) -> toBeTrue ();
});
it ( 'generates a sitemap with single pages and alternates across multiple locales' , function () {
// GIVEN
Route :: name ( 'about' ) -> get ( '{locale}/about' , fn () => 'About' );
Route :: name ( 'contact' ) -> get ( '{locale}/contact' , fn () => 'Contact' );
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [ 'about' , 'contact' ],
'supported_locales' => [ 'fr' , 'en' ],
'cruds' => [],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator ( $config , new SitemapHelper ( $config , new DefaultRouteLocalizer ()));
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap ) -> toBeInstanceOf ( Sitemap :: class )
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/about' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/en/about' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/contact' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/en/contact' )) -> toBeTrue ();
$aboutTagFr = collect ( $sitemap -> getTags ()) -> first ( fn ( mixed $tag ) : bool => $tag instanceof Url && $tag -> url === 'http://localhost/fr/about' );
Assert :: isInstanceOf ( $aboutTagFr , Url :: class );
expect ( $aboutTagFr -> alternates ) -> toHaveCount ( 1 )
-> and ( $aboutTagFr -> alternates [ 0 ] -> url ) -> toBe ( 'http://localhost/en/about' )
-> and ( $aboutTagFr -> alternates [ 0 ] -> locale ) -> toBe ( 'en' );
});
it ( 'ignores non-existing routes gracefully' , function () {
// GIVEN
Route :: name ( 'about' ) -> get ( '{locale}/about' , fn () => 'About' );
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [ 'about' , 'inexisting-route' ],
'supported_locales' => [ 'fr' ],
'cruds' => [],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/about' )) -> toBeTrue ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 1 );
});
it ( 'generates a sitemap with cruds models and alternates' , function () {
// GIVEN
Schema :: create ( 'model_classes' , function ( Blueprint $table ) {
$table -> id ();
$table -> timestamps ();
});
ModelClass :: create ();
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' , 'en' ],
'cruds' => [
[
'model' => ModelClass :: class ,
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap ) -> toBeInstanceOf ( Sitemap :: class )
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/posts' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/en/posts' )) -> toBeTrue ();
$crudTagFr = collect ( $sitemap -> getTags ())
-> first ( fn ( mixed $tag ) : bool => $tag instanceof Url && $tag -> url === 'http://localhost/fr/posts' );
Assert :: isInstanceOf ( $crudTagFr , Url :: class );
expect ( $crudTagFr -> alternates ) -> toHaveCount ( 1 )
-> and ( $crudTagFr -> alternates [ 0 ] -> url ) -> toBe ( 'http://localhost/en/posts' )
-> and ( $crudTagFr -> alternates [ 0 ] -> locale ) -> toBe ( 'en' );
});
it ( 'generates a combined sitemap with single pages and cruds' , function () {
// GIVEN
Route :: name ( 'home' ) -> get ( '{locale}/home' , fn () => 'Home' );
Schema :: create ( 'model_classes' , function ( Blueprint $table ) {
$table -> id ();
$table -> timestamps ();
});
ModelClass :: create ();
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [ 'home' ],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
'model' => ModelClass :: class ,
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/home' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/posts' )) -> toBeTrue ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 2 );
});
2026-10-15 12:04:03 +02:00
it ( 'generates sitemap with cruds matching documentation config example (model_filters, closure relation_filters, allow_null_relation, and multi-crud)' , function () {
2026-10-14 17:32:12 +02:00
// GIVEN
Schema :: create ( 'ranges' , function ( Blueprint $table ) {
$table -> id ();
$table -> boolean ( 'status' ) -> default ( true );
$table -> timestamps ();
});
Schema :: create ( 'products' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'slug' );
$table -> boolean ( 'published' ) -> default ( true );
$table -> foreignId ( 'range_id' ) -> nullable ();
$table -> timestamps ();
});
Schema :: create ( 'articles' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'slug' );
$table -> timestamps ();
});
$activeRange = Range :: create ([ 'status' => true ]);
$inactiveRange = Range :: create ([ 'status' => false ]);
Product :: create ([ 'slug' => 'p-active-range' , 'published' => true , 'range_id' => $activeRange -> id ]);
Product :: create ([ 'slug' => 'p-no-range' , 'published' => true , 'range_id' => null ]);
Product :: create ([ 'slug' => 'p-inactive-range' , 'published' => true , 'range_id' => $inactiveRange -> id ]);
Product :: create ([ 'slug' => 'p-draft' , 'published' => false , 'range_id' => $activeRange -> id ]);
Article :: create ([ 'slug' => 'first-article' ]);
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
2026-10-15 14:42:20 +02:00
'model' => Product :: class ,
2026-10-15 12:04:03 +02:00
'model_filters' => [
2026-10-14 17:32:12 +02:00
[ 'published' , '=' , true ],
],
2026-10-15 12:04:03 +02:00
'relation_filters' => [
2026-10-14 17:32:12 +02:00
'range' => fn ( Builder $q ) => $q -> where ( 'status' , true ),
],
2026-10-15 12:04:03 +02:00
'allow_null_relation' => [ 'range' ],
2026-10-14 17:32:12 +02:00
],
[
'model' => Article :: class ,
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/products/p-active-range' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/p-no-range' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/p-inactive-range' )) -> toBeFalse ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/p-draft' )) -> toBeFalse ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/articles/first-article' )) -> toBeTrue ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 3 );
});
2026-10-15 12:04:03 +02:00
it ( 'filters crud models requiring existing relation when relation is not in allow_null_relation' , function () {
2026-10-14 17:32:12 +02:00
// GIVEN
Schema :: create ( 'ranges' , function ( Blueprint $table ) {
$table -> id ();
$table -> boolean ( 'status' ) -> default ( true );
$table -> timestamps ();
});
Schema :: create ( 'products' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'slug' );
$table -> boolean ( 'published' ) -> default ( true );
$table -> foreignId ( 'range_id' ) -> nullable ();
$table -> timestamps ();
});
$activeRange = Range :: create ([ 'status' => true ]);
Product :: create ([ 'slug' => 'with-range' , 'published' => true , 'range_id' => $activeRange -> id ]);
Product :: create ([ 'slug' => 'without-range' , 'published' => true , 'range_id' => null ]);
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
2026-10-15 14:42:20 +02:00
'model' => Product :: class ,
2026-10-15 12:04:03 +02:00
'relation_filters' => [
2026-10-14 17:32:12 +02:00
'range' => fn ( Builder $q ) => $q -> where ( 'status' , true ),
],
2026-10-15 12:04:03 +02:00
'allow_null_relation' => [],
2026-10-14 17:32:12 +02:00
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/products/with-range' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/without-range' )) -> toBeFalse ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 1 );
});
2026-10-15 12:04:03 +02:00
it ( 'filters crud models using relation_filters with array constraint' , function () {
2026-10-14 17:32:12 +02:00
// GIVEN
Schema :: create ( 'ranges' , function ( Blueprint $table ) {
$table -> id ();
$table -> boolean ( 'status' ) -> default ( true );
$table -> timestamps ();
});
Schema :: create ( 'products' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'slug' );
$table -> boolean ( 'published' ) -> default ( true );
$table -> foreignId ( 'range_id' ) -> nullable ();
$table -> timestamps ();
});
$activeRange = Range :: create ([ 'status' => true ]);
$inactiveRange = Range :: create ([ 'status' => false ]);
Product :: create ([ 'slug' => 'in-active' , 'published' => true , 'range_id' => $activeRange -> id ]);
Product :: create ([ 'slug' => 'in-inactive' , 'published' => true , 'range_id' => $inactiveRange -> id ]);
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
2026-10-15 14:42:20 +02:00
'model' => Product :: class ,
2026-10-15 12:04:03 +02:00
'relation_filters' => [
2026-10-14 17:32:12 +02:00
'range' => [ 'status' => true ],
],
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/products/in-active' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/in-inactive' )) -> toBeFalse ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 1 );
});
2026-10-15 12:04:03 +02:00
it ( 'filters crud models using relation_filters with default string boolean constraint' , function () {
2026-10-14 17:32:12 +02:00
// GIVEN
Schema :: create ( 'ranges' , function ( Blueprint $table ) {
$table -> id ();
$table -> boolean ( 'status' ) -> default ( true );
$table -> timestamps ();
});
Schema :: create ( 'products' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'slug' );
$table -> boolean ( 'published' ) -> default ( true );
$table -> foreignId ( 'range_id' ) -> nullable ();
$table -> timestamps ();
});
$activeRange = Range :: create ([ 'status' => true ]);
$inactiveRange = Range :: create ([ 'status' => false ]);
Product :: create ([ 'slug' => 'in-active' , 'published' => true , 'range_id' => $activeRange -> id ]);
Product :: create ([ 'slug' => 'in-inactive' , 'published' => true , 'range_id' => $inactiveRange -> id ]);
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
2026-10-15 14:42:20 +02:00
'model' => Product :: class ,
2026-10-15 12:04:03 +02:00
'relation_filters' => [
2026-10-14 17:32:12 +02:00
'range' => 'status' ,
],
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$sitemap = $generator -> generate ();
// THEN
expect ( $sitemap -> hasUrl ( 'http://localhost/fr/products/in-active' )) -> toBeTrue ()
-> and ( $sitemap -> hasUrl ( 'http://localhost/fr/products/in-inactive' )) -> toBeFalse ()
-> and ( $sitemap -> getTags ()) -> toHaveCount ( 1 );
});
it ( 'throws SitemapException when an error occurs during sitemap generation' , function () {
// GIVEN
/** @var \Uwil\SitemapGenerator\Tests\TestCase $this */
$config = new SitemapConfig ( new ConfigRepository ([
SitemapConfig :: CONFIG_KEY => [
'single_pages' => [],
'supported_locales' => [ 'fr' ],
'cruds' => [
[
// NOTE: This will cause an error because the 'products' table is not created
'model' => Product :: class ,
],
],
'schedule' => false ,
'cron' => '* * * * *' ,
'localizer' => 'default' ,
2026-10-15 14:42:20 +02:00
'output' => [
'storage_disk' => $this -> outputDisk ,
'path' => $this -> outputPath ,
],
2026-10-14 17:32:12 +02:00
],
]));
$generator = new SitemapGenerator (
$config ,
new SitemapHelper (
$config ,
new DefaultRouteLocalizer ()
)
);
// WHEN
$action = fn () => $generator -> generate ();
// THEN
expect ( $action ) -> toThrow (
SitemapException :: class ,
'An error occurred while generating the sitemap'
);
});
});