Skip to content

Commit ed159a1

Browse files
committed
test(canonical): cover slugless singleton canonical and clean listing canonical
Unit tests for ContentHelper canonical route resolution: - singleton -> slugless listing route (no slugOrId) - non-singleton -> record route with slug (regression guard) - homepage singleton -> homepage route, not listing (precedence) - non-Content input is ignored Functional tests: - listing canonical omits volatile query params (order/status/filters) - a singleton's slugged record URL canonicalizes to its slugless URL, and the slugless URL is self-referential (end-to-end) Support changes: - add an 'about' non-homepage singleton to the test-scaffolding content types so the end-to-end singleton test has a record to exercise - ContentTypesParserTest: demo content-type count 9 -> 10, assert 'about' key - phpunit.xml.dist: set BOLT_CANONICAL so the Canonical service bootstraps in the test environment (otherwise it fatals on an empty value)
1 parent 123957b commit ed159a1

6 files changed

Lines changed: 240 additions & 1 deletion

File tree

config/bolt/contenttypes.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,27 @@ validationdemos:
566566
min: 1
567567
max: 2
568568

569+
# This contenttype is here to use for (automated) tests.
570+
# A non-homepage singleton: Bolt serves a singleton at its slugless listing URL
571+
# (the ListingController forwards a singleton "listing" to the record), so its
572+
# canonical should be `/about`, not the record-detail route `/about/{slug}`.
573+
about:
574+
name: About
575+
singular_name: About
576+
slug: about
577+
fields:
578+
title:
579+
type: text
580+
label: Title
581+
slug:
582+
type: slug
583+
uses: title
584+
group: meta
585+
viewless: false
586+
singleton: true
587+
icon_many: "fa:info-circle"
588+
icon_one: "fa:info-circle"
589+
569590
# Possible field types:
570591
#
571592
# text - varchar(256) - input type text.

phpunit.xml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<!-- ###+ doctrine/doctrine-bundle ### -->
1919
<env name="DATABASE_URL" value="sqlite:///%kernel.project_dir%/var/data/bolt.test.sqlite" force="true"/>
2020
<!-- ###- doctrine/doctrine-bundle ### -->
21+
<!-- Set canonical in the general config. Keep empty to not use it. -->
22+
<env name="BOLT_CANONICAL" value="http://localhost"/>
2123
<!-- ###+ nelmio/cors-bundle ### -->
2224
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
2325
<!-- ###- nelmio/cors-bundle ### -->

tests/php/Configuration/Parser/ContentTypesParserTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ public function testHasConfig(): void
7070
$contentTypesParser = new ContentTypesParser($this->getProjectDir(), $generalParser->parse(), self::DEFAULT_LOCALE, self::ALLOWED_LOCALES);
7171
$config = $contentTypesParser->parse();
7272

73-
$this->assertCount(9, $config);
73+
$this->assertCount(10, $config);
7474

7575
$this->assertArrayHasKey('homepage', $config);
76+
$this->assertArrayHasKey('about', $config);
7677
$this->assertCount(self::AMOUNT_OF_ATTRIBUTES_IN_CONTENT_TYPE, $config['homepage']);
7778

7879
$this->assertSame('Homepage', $config['homepage']['name']);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bolt\Tests\Controller\Frontend;
6+
7+
use Bolt\Tests\DbAwareTestCase;
8+
9+
/**
10+
* The listing canonical must be the clean listing URL. Query params parsed for
11+
* the listing (order/status/filters) are volatile and must not leak into the
12+
* <link rel="canonical">.
13+
*/
14+
class ListingCanonicalTest extends DbAwareTestCase
15+
{
16+
public function testListingCanonicalOmitsQueryParams(): void
17+
{
18+
$crawler = $this->client->request('GET', '/showcases?order=title');
19+
20+
$this->assertResponseIsSuccessful();
21+
22+
$canonical = $crawler->filter('link[rel="canonical"]')->attr('href');
23+
24+
$this->assertStringEndsWith('/showcases', $canonical);
25+
$this->assertStringNotContainsString('?', $canonical);
26+
$this->assertStringNotContainsString('order', $canonical);
27+
}
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bolt\Tests\Controller\Frontend;
6+
7+
use Bolt\Entity\Content;
8+
use Bolt\Tests\DbAwareTestCase;
9+
10+
/**
11+
* End-to-end: a singleton is reachable both at its slugless URL (`/about`) and at
12+
* the record-detail route (`/about/{slug}`). Both must declare the slugless URL
13+
* as their canonical, so the two URLs consolidate to one.
14+
*/
15+
class SingletonCanonicalTest extends DbAwareTestCase
16+
{
17+
public function testSingletonRecordUrlCanonicalizesToSluglessUrl(): void
18+
{
19+
/** @var Content|null $record */
20+
$record = $this->getEm()->getRepository(Content::class)
21+
->findOneBy(['contentType' => 'about']);
22+
23+
$this->assertNotNull($record, 'Expected the "about" singleton fixture to be seeded.');
24+
25+
$singularSlug = $record->getContentTypeSingularSlug();
26+
$slug = $record->getSlug();
27+
$expectedCanonical = 'http://localhost/' . $singularSlug;
28+
29+
// The slugged record URL must canonicalize to the slugless singleton URL.
30+
$crawler = $this->client->request('GET', sprintf('/%s/%s', $singularSlug, $slug));
31+
$this->assertResponseIsSuccessful();
32+
$this->assertSame(
33+
$expectedCanonical,
34+
$crawler->filter('link[rel="canonical"]')->attr('href')
35+
);
36+
37+
// The slugless URL itself is self-referential (same canonical).
38+
$crawler = $this->client->request('GET', '/' . $singularSlug);
39+
$this->assertResponseIsSuccessful();
40+
$this->assertSame(
41+
$expectedCanonical,
42+
$crawler->filter('link[rel="canonical"]')->attr('href')
43+
);
44+
}
45+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bolt\Tests\Utils;
6+
7+
use Bolt\Canonical;
8+
use Bolt\Configuration\Config;
9+
use Bolt\Configuration\Content\ContentType;
10+
use Bolt\Entity\Content;
11+
use Bolt\Twig\LocaleExtension;
12+
use Bolt\Utils\ContentHelper;
13+
use PHPUnit\Framework\TestCase;
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\RequestStack;
16+
17+
/**
18+
* Canonical route/params resolution, with focus on the singleton handling:
19+
* a singleton is served at its slugless listing URL (ListingController forwards
20+
* a singleton listing to the record), so its canonical must point there
21+
* (`/{singularSlug}`) and not at the record-detail route (`/{singularSlug}/{slug}`).
22+
*/
23+
class ContentHelperTest extends TestCase
24+
{
25+
public function testSingletonCanonicalUsesSluglessListingRoute(): void
26+
{
27+
$content = $this->createContent(
28+
singleton: true,
29+
singularSlug: 'contact',
30+
slug: 'contact-info',
31+
recordSlug: 'contact-2'
32+
);
33+
34+
$canonical = $this->createMock(Canonical::class);
35+
$canonical->expects($this->once())
36+
->method('setPath')
37+
->with('listing_locale', [
38+
'contentTypeSlug' => 'contact',
39+
'_locale' => 'en',
40+
]);
41+
42+
$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
43+
}
44+
45+
public function testNonSingletonCanonicalUsesRecordRouteWithSlug(): void
46+
{
47+
$content = $this->createContent(
48+
singleton: false,
49+
singularSlug: 'entry',
50+
slug: 'entries',
51+
recordSlug: 'my-post',
52+
recordRoute: 'record'
53+
);
54+
55+
$canonical = $this->createMock(Canonical::class);
56+
$canonical->expects($this->once())
57+
->method('setPath')
58+
->with('record', [
59+
'contentTypeSlug' => 'entry',
60+
'slugOrId' => 'my-post',
61+
'_locale' => 'en',
62+
]);
63+
64+
$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
65+
}
66+
67+
public function testHomepageCanonicalIsNotAffectedBySingletonHandling(): void
68+
{
69+
// The homepage is itself a singleton, but must keep resolving to the
70+
// homepage route, not the listing route.
71+
$content = $this->createContent(
72+
singleton: true,
73+
singularSlug: 'homepage',
74+
slug: 'homepage',
75+
recordSlug: 'homepage'
76+
);
77+
78+
$canonical = $this->createMock(Canonical::class);
79+
$canonical->expects($this->once())
80+
->method('setPath')
81+
->with('homepage_locale', [
82+
'_locale' => 'en',
83+
]);
84+
85+
$this->createContentHelper($canonical)->setCanonicalPath($content, 'en');
86+
}
87+
88+
public function testNonContentIsIgnored(): void
89+
{
90+
$canonical = $this->createMock(Canonical::class);
91+
$canonical->expects($this->never())
92+
->method('setPath');
93+
94+
$this->createContentHelper($canonical)->setCanonicalPath(null, 'en');
95+
}
96+
97+
private function createContent(
98+
bool $singleton,
99+
string $singularSlug,
100+
string $slug,
101+
string $recordSlug,
102+
?string $recordRoute = null
103+
): Content {
104+
$definition = $this->createMock(ContentType::class);
105+
$definition->method('get')->willReturnCallback(
106+
static fn (string $key) => match ($key) {
107+
'singleton' => $singleton,
108+
'record_route' => $recordRoute,
109+
default => null,
110+
}
111+
);
112+
113+
$content = $this->createMock(Content::class);
114+
$content->method('getDefinition')->willReturn($definition);
115+
$content->method('getContentTypeSingularSlug')->willReturn($singularSlug);
116+
$content->method('getContentTypeSlug')->willReturn($slug);
117+
$content->method('getSlug')->willReturn($recordSlug);
118+
$content->method('getId')->willReturn(1);
119+
120+
return $content;
121+
}
122+
123+
private function createContentHelper(Canonical $canonical): ContentHelper
124+
{
125+
$config = $this->createMock(Config::class);
126+
// Only `general/homepage` is consulted (via isHomepage()); a content type
127+
// is the homepage when its slug matches this value.
128+
$config->method('get')->willReturnCallback(
129+
static fn (string $path) => $path === 'general/homepage' ? 'homepage' : null
130+
);
131+
132+
$requestStack = $this->createMock(RequestStack::class);
133+
$requestStack->method('getCurrentRequest')->willReturn(new Request());
134+
135+
return new ContentHelper(
136+
$canonical,
137+
$requestStack,
138+
$config,
139+
$this->createMock(LocaleExtension::class)
140+
);
141+
}
142+
}

0 commit comments

Comments
 (0)