Merge pull request #2136 from acelaya-forks/release/4.1.1

Release/4.1.1
This commit is contained in:
Alejandro Celaya 2024-05-23 09:21:56 +02:00 committed by GitHub
commit b2dabf06bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 112 additions and 58 deletions

View file

@ -15,7 +15,7 @@ jobs:
- runtime: 'rr' - runtime: 'rr'
tag-suffix: 'roadrunner' tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64' platforms: 'linux/arm64/v8,linux/amd64'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
secrets: inherit secrets: inherit
with: with:
image-name: shlinkio/shlink image-name: shlinkio/shlink

View file

@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [4.1.1] - 2024-05-23
### Added
* *Nothing*
### Changed
* Use new reusable workflow to publish docker image
* [#2015](https://github.com/shlinkio/shlink/issues/2015) Update to PHPUnit 11.
* [#2130](https://github.com/shlinkio/shlink/pull/2130) Replace deprecated `pugx/shortid-php` package with `hidehalo/nanoid-php`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2111](https://github.com/shlinkio/shlink/issues/2111) Fix typo in OAS docs examples where redirect rules with `query-param` condition type were defined as `query`.
* [#2129](https://github.com/shlinkio/shlink/issues/2129) Fix error when resolving title for sites not using UTF-8 charset (detected with Japanese charsets).
## [4.1.0] - 2024-04-14 ## [4.1.0] - 2024-04-14
### Added ### Added
* [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit. * [#1330](https://github.com/shlinkio/shlink/issues/1330) All visit-related endpoints now expose the `visitedUrl` prop for any visit.
@ -824,3 +844,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Fixed ### Fixed
* *Nothing* * *Nothing*
## Older versions
* [2.x.x](docs/changelog-archive/CHANGELOG-2.x.md)
* [1.x.x](docs/changelog-archive/CHANGELOG-1.x.md)

View file

@ -16,6 +16,7 @@
"ext-curl": "*", "ext-curl": "*",
"ext-gd": "*", "ext-gd": "*",
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*", "ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1", "akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^3.0.2", "cakephp/chronos": "^3.0.2",
@ -26,9 +27,10 @@
"friendsofphp/proxy-manager-lts": "^1.0", "friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0", "geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5", "guzzlehttp/guzzle": "^7.5",
"hidehalo/nanoid-php": "^1.1",
"jaybizzle/crawler-detect": "^1.2.116", "jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8", "laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13", "laminas/laminas-config-aggregator": "^1.15",
"laminas/laminas-diactoros": "^3.3", "laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27", "laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21", "laminas/laminas-servicemanager": "^3.21",
@ -40,7 +42,6 @@
"mlocati/ip-lib": "^1.18", "mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8", "mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8", "pagerfanta/core": "^3.8",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1", "shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "^6.1", "shlinkio/shlink-common": "^6.1",
@ -63,13 +64,13 @@
"require-dev": { "require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1", "devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1", "devster/ubench": "^2.1",
"phpstan/phpstan": "^1.10", "phpstan/phpstan": "^1.11",
"phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-doctrine": "^1.4",
"phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.3", "phpstan/phpstan-symfony": "^1.4",
"phpunit/php-code-coverage": "^10.1", "phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^9.0", "phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^10.4", "phpunit/phpunit": "^11.1",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0", "shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1", "shlinkio/shlink-test-utils": "^4.1",

View file

@ -12,7 +12,6 @@ const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema. const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const DEFAULT_QR_CODE_SIZE = 300; const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0; const DEFAULT_QR_CODE_MARGIN = 0;

View file

@ -105,7 +105,7 @@ services:
shlink_db_ms: shlink_db_ms:
container_name: shlink_db_ms container_name: shlink_db_ms
image: mcr.microsoft.com/mssql/server:2019-latest image: mcr.microsoft.com/mssql/server:2022-latest
ports: ports:
- "1433:1433" - "1433:1433"
environment: environment:

View file

@ -77,12 +77,12 @@
"priority": 3, "priority": 3,
"conditions": [ "conditions": [
{ {
"type": "query", "type": "query-param",
"matchKey": "foo", "matchKey": "foo",
"matchValue": "bar" "matchValue": "bar"
}, },
{ {
"type": "query", "type": "query-param",
"matchKey": "hello", "matchKey": "hello",
"matchValue": "world" "matchValue": "world"
} }
@ -209,12 +209,12 @@
"longUrl": "https://example.com/query-foo-bar-hello-world", "longUrl": "https://example.com/query-foo-bar-hello-world",
"conditions": [ "conditions": [
{ {
"type": "query", "type": "query-param",
"matchKey": "foo", "matchKey": "foo",
"matchValue": "bar" "matchValue": "bar"
}, },
{ {
"type": "query", "type": "query-param",
"matchKey": "hello", "matchKey": "hello",
"matchValue": "world" "matchValue": "world"
} }
@ -280,12 +280,12 @@
"priority": 3, "priority": 3,
"conditions": [ "conditions": [
{ {
"type": "query", "type": "query-param",
"matchKey": "foo", "matchKey": "foo",
"matchValue": "bar" "matchValue": "bar"
}, },
{ {
"type": "query", "type": "query-param",
"matchKey": "hello", "matchKey": "hello",
"matchValue": "world" "matchValue": "world"
} }

View file

@ -34,8 +34,8 @@ class MatomoSendVisitsCommandTest extends TestCase
} }
#[Test] #[Test]
#[TestWith([true])] #[TestWith([true], 'interactive')]
#[TestWith([false])] #[TestWith([false], 'not interactive')]
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
{ {
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult()); $this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());

View file

@ -25,6 +25,7 @@ class CliTestUtils
$command = $generator->testDouble( $command = $generator->testDouble(
Command::class, Command::class,
mockObject: true, mockObject: true,
markAsMockObject: true,
callOriginalConstructor: false, callOriginalConstructor: false,
callOriginalClone: false, callOriginalClone: false,
cloneArguments: false, cloneArguments: false,

View file

@ -9,11 +9,11 @@ use Cake\Chronos\Chronos;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Query;
use Hidehalo\Nanoid\Client as NanoidClient;
use Jaybizzle\CrawlerDetect\CrawlerDetect; use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
@ -37,15 +37,15 @@ use function ucfirst;
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
{ {
static $shortIdFactory; static $nanoIdClient;
if ($shortIdFactory === null) { if ($nanoIdClient === null) {
$shortIdFactory = new ShortIdFactory(); $nanoIdClient = new NanoidClient();
} }
$alphabet = $mode === ShortUrlMode::STRICT $alphabet = $mode === ShortUrlMode::STRICT
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
: '0123456789abcdefghijklmnopqrstuvwxyz'; : '0123456789abcdefghijklmnopqrstuvwxyz';
return $shortIdFactory->generate($length, $alphabet)->serialize(); return $nanoIdClient->formattedId($alphabet, $length);
} }
function parseDateFromQuery(array $query, string $dateName): ?Chronos function parseDateFromQuery(array $query, string $dateName): ?Chronos

View file

@ -12,20 +12,24 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Throwable; use Throwable;
use function html_entity_decode; use function html_entity_decode;
use function mb_convert_encoding;
use function preg_match; use function preg_match;
use function str_contains; use function str_contains;
use function str_starts_with; use function str_starts_with;
use function strtolower; use function strtolower;
use function trim; use function trim;
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{ {
public const MAX_REDIRECTS = 15; public const MAX_REDIRECTS = 15;
public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/121.0.0.0 Safari/537.36'; . 'Chrome/121.0.0.0 Safari/537.36';
// Matches the value inside a html title tag
private const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i';
// Matches the charset inside a Content-Type header
private const CHARSET_VALUE = '/charset=([^;]+)/i';
public function __construct( public function __construct(
private ClientInterface $httpClient, private ClientInterface $httpClient,
private UrlShortenerOptions $options, private UrlShortenerOptions $options,
@ -53,7 +57,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
return $data; return $data;
} }
$title = $this->tryToResolveTitle($response); $title = $this->tryToResolveTitle($response, $contentType);
return $title !== null ? $data->withResolvedTitle($title) : $data; return $title !== null ? $data->withResolvedTitle($title) : $data;
} }
@ -76,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
} }
} }
private function tryToResolveTitle(ResponseInterface $response): ?string private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string
{ {
$collectedBody = ''; $collectedBody = '';
$body = $response->getBody(); $body = $response->getBody();
@ -84,12 +88,19 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH
while (! str_contains($collectedBody, '</title>') && ! $body->eof()) { while (! str_contains($collectedBody, '</title>') && ! $body->eof()) {
$collectedBody .= $body->read(1024); $collectedBody .= $body->read(1024);
} }
preg_match(TITLE_TAG_VALUE, $collectedBody, $matches);
return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null;
}
private function normalizeTitle(string $title): string // Try to match the title from the <title /> tag
{ preg_match(self::TITLE_TAG_VALUE, $collectedBody, $titleMatches);
if (! isset($titleMatches[1])) {
return null;
}
// Get the page's charset from Content-Type header
preg_match(self::CHARSET_VALUE, $contentType, $charsetMatches);
$title = isset($charsetMatches[1])
? mb_convert_encoding($titleMatches[1], 'utf8', $charsetMatches[1])
: $titleMatches[1];
return html_entity_decode(trim($title)); return html_entity_decode(trim($title));
} }
} }

View file

@ -10,6 +10,7 @@ use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvokedCount as InvokedCountMatcher;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
@ -60,14 +61,19 @@ class NotFoundRedirectHandlerTest extends TestCase
public static function provideNonRedirectScenarios(): iterable public static function provideNonRedirectScenarios(): iterable
{ {
$exactly = static fn (int $expectedCount) => new InvokedCountMatcher($expectedCount);
$once = static fn () => $exactly(1);
yield 'no domain' => [function ( yield 'no domain' => [function (
MockObject&DomainServiceInterface $domainService, MockObject&DomainServiceInterface $domainService,
MockObject&NotFoundRedirectResolverInterface $resolver, MockObject&NotFoundRedirectResolverInterface $resolver,
) use (
$once,
): void { ): void {
$domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn( $domainService->expects($once())->method('findByAuthority')->withAnyParameters()->willReturn(
null, null,
); );
$resolver->expects(self::once())->method('resolveRedirectResponse')->with( $resolver->expects($once())->method('resolveRedirectResponse')->with(
self::isInstanceOf(NotFoundType::class), self::isInstanceOf(NotFoundType::class),
self::isInstanceOf(NotFoundRedirectOptions::class), self::isInstanceOf(NotFoundRedirectOptions::class),
self::isInstanceOf(UriInterface::class), self::isInstanceOf(UriInterface::class),
@ -76,12 +82,15 @@ class NotFoundRedirectHandlerTest extends TestCase
yield 'non-redirecting domain' => [function ( yield 'non-redirecting domain' => [function (
MockObject&DomainServiceInterface $domainService, MockObject&DomainServiceInterface $domainService,
MockObject&NotFoundRedirectResolverInterface $resolver, MockObject&NotFoundRedirectResolverInterface $resolver,
) use (
$once,
$exactly,
): void { ): void {
$domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn( $domainService->expects($once())->method('findByAuthority')->withAnyParameters()->willReturn(
Domain::withAuthority(''), Domain::withAuthority(''),
); );
$callCount = 0; $callCount = 0;
$resolver->expects(self::exactly(2))->method('resolveRedirectResponse')->willReturnCallback( $resolver->expects($exactly(2))->method('resolveRedirectResponse')->willReturnCallback(
function (mixed $arg1, mixed $arg2, mixed $arg3) use (&$callCount) { function (mixed $arg1, mixed $arg2, mixed $arg3) use (&$callCount) {
Assert::assertInstanceOf(NotFoundType::class, $arg1); Assert::assertInstanceOf(NotFoundType::class, $arg1);
Assert::assertInstanceOf($callCount === 0 ? Domain::class : NotFoundRedirectOptions::class, $arg2); Assert::assertInstanceOf($callCount === 0 ? Domain::class : NotFoundRedirectOptions::class, $arg2);

View file

@ -10,6 +10,7 @@ use Exception;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvokedCount as InvokedCountMatcher;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
@ -146,30 +147,34 @@ class NotifyVisitToRabbitMqTest extends TestCase
public static function providePayloads(): iterable public static function providePayloads(): iterable
{ {
$exactly = static fn (int $expectedCount) => new InvokedCountMatcher($expectedCount);
$once = static fn () => $exactly(1);
$never = static fn () => $exactly(0);
yield 'non-orphan visit' => [ yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()),
function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void {
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$updatesGenerator->expects(self::never())->method('newOrphanVisitUpdate'); $updatesGenerator->expects($never())->method('newOrphanVisitUpdate');
$updatesGenerator->expects(self::once())->method('newVisitUpdate')->withAnyParameters()->willReturn( $updatesGenerator->expects($once())->method('newVisitUpdate')->withAnyParameters()->willReturn(
$update, $update,
); );
$updatesGenerator->expects(self::once())->method('newShortUrlVisitUpdate')->willReturn($update); $updatesGenerator->expects($once())->method('newShortUrlVisitUpdate')->willReturn($update);
}, },
function (MockObject & PublishingHelperInterface $helper): void { function (MockObject & PublishingHelperInterface $helper) use ($exactly): void {
$helper->expects(self::exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class)); $helper->expects($exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class));
}, },
]; ];
yield 'orphan visit' => [ yield 'orphan visit' => [
Visit::forBasePath(Visitor::emptyInstance()), Visit::forBasePath(Visitor::emptyInstance()),
function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void {
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$updatesGenerator->expects(self::once())->method('newOrphanVisitUpdate')->willReturn($update); $updatesGenerator->expects($once())->method('newOrphanVisitUpdate')->willReturn($update);
$updatesGenerator->expects(self::never())->method('newVisitUpdate'); $updatesGenerator->expects($never())->method('newVisitUpdate');
$updatesGenerator->expects(self::never())->method('newShortUrlVisitUpdate'); $updatesGenerator->expects($never())->method('newShortUrlVisitUpdate');
}, },
function (MockObject & PublishingHelperInterface $helper): void { function (MockObject & PublishingHelperInterface $helper) use ($once): void {
$helper->expects(self::once())->method('publishUpdate')->with(self::isInstanceOf(Update::class)); $helper->expects($once())->method('publishUpdate')->with(self::isInstanceOf(Update::class));
}, },
]; ];
} }

View file

@ -37,8 +37,8 @@ class MatomoTrackerBuilderTest extends TestCase
{ {
$tracker = $this->builder()->buildMatomoTracker(); $tracker = $this->builder()->buildMatomoTracker();
self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line self::assertEquals('api_token', $tracker->token_auth);
self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line self::assertEquals(5, $tracker->idSite);
self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout()); self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout());
self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout());
} }

View file

@ -12,6 +12,7 @@ use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\Stream; use Laminas\Diactoros\Stream;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -89,10 +90,12 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
} }
#[Test] #[Test]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void #[TestWith(['TEXT/html; charset=utf-8'], name: 'charset')]
#[TestWith(['TEXT/html'], name: 'no charset')]
public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void
{ {
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle()); $this->expectRequestToBeCalled()->willReturn($this->respWithTitle($contentType));
$result = $this->helper(autoResolveTitles: true)->processTitle($data); $result = $this->helper(autoResolveTitles: true)->processTitle($data);
@ -122,10 +125,10 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
return new Response($body, 200, ['Content-Type' => 'text/html']); return new Response($body, 200, ['Content-Type' => 'text/html']);
} }
private function respWithTitle(): Response private function respWithTitle(string $contentType): Response
{ {
$body = $this->createStreamWithContent('<title data-foo="bar"> Resolved &quot;title&quot; </title>'); $body = $this->createStreamWithContent('<title data-foo="bar"> Resolved &quot;title&quot; </title>');
return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); return new Response($body, 200, ['Content-Type' => $contentType]);
} }
private function createStreamWithContent(string $content): Stream private function createStreamWithContent(string $content): Stream

View file

@ -4,8 +4,6 @@ includes:
- vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-phpunit/rules.neon - vendor/phpstan/phpstan-phpunit/rules.neon
parameters: parameters:
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
symfony: symfony:
console_application_loader: 'config/cli-app.php' console_application_loader: 'config/cli-app.php'
doctrine: doctrine:
@ -14,3 +12,5 @@ parameters:
ignoreErrors: ignoreErrors:
- '#should return int<0, max> but returns int#' - '#should return int<0, max> but returns int#'
- '#expects -1|int<1, max>, int given#' - '#expects -1|int<1, max>, int given#'
- identifier: missingType.generics
- identifier: missingType.iterableValue