Merge pull request #1668 from acelaya-forks/feature/device-long-urls

Feature/device long urls
This commit is contained in:
Alejandro Celaya 2023-01-22 12:50:33 +01:00 committed by GitHub
commit 758dac47c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
104 changed files with 1431 additions and 647 deletions

View file

@ -60,6 +60,8 @@ jobs:
strategy: strategy:
matrix: matrix:
php-version: ['8.1', '8.2'] php-version: ['8.1', '8.2']
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres

View file

@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased] ## [Unreleased]
### Added ### Added
* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type.
For the moment, only `android`, `ios` and `desktop` can have their own specific long URL, and when the visitor cannot be matched against any of them, the regular long URL will be used.
In the future, more granular device types could be added if appropriate (iOS tablet, android table, tablet, mobile phone, Linux, Mac, Windows, etc).
In order to match the visitor's device, the `User-Agent` header is used.
* [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. * [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint.
* [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint. * [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint.
* [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308. * [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308.
@ -20,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing* * *Nothing*
### Deprecated ### Deprecated
* *Nothing* * [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead
### Removed ### Removed
* *Nothing* * *Nothing*

View file

@ -40,12 +40,13 @@
"mezzio/mezzio-problem-details": "^1.7", "mezzio/mezzio-problem-details": "^1.7",
"mezzio/mezzio-swoole": "^4.5", "mezzio/mezzio-swoole": "^4.5",
"mlocati/ip-lib": "^1.18", "mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^3.74",
"ocramius/proxy-manager": "^2.14", "ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6", "pagerfanta/core": "^3.6",
"php-middleware/request-id": "^4.1", "php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.5", "ramsey/uuid": "^4.5",
"shlinkio/shlink-common": "^5.2", "shlinkio/shlink-common": "dev-main#61d26e7 as 5.3",
"shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4", "shlinkio/shlink-config": "dev-main#2a5b5c2 as 2.4",
"shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^5.0", "shlinkio/shlink-importer": "^5.0",
@ -73,7 +74,7 @@
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"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": "^3.3", "shlinkio/shlink-test-utils": "^3.4",
"symfony/var-dumper": "^6.1", "symfony/var-dumper": "^6.1",
"veewee/composer-run-parallel": "^1.1" "veewee/composer-run-parallel": "^1.1"
}, },
@ -96,7 +97,8 @@
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
"ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db", "ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db",
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test", "ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db" "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db",
"ShlinkioApiTest\\Shlink\\Core\\": "module/Core/test-api"
}, },
"files": [ "files": [
"config/test/constants.php" "config/test/constants.php"

View file

@ -15,6 +15,7 @@ use function class_exists;
use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\openswooleIsInstalled; use function Shlinkio\Shlink\Config\openswooleIsInstalled;
use function Shlinkio\Shlink\Config\runningInRoadRunner; use function Shlinkio\Shlink\Config\runningInRoadRunner;
use function Shlinkio\Shlink\Core\enumValues;
use const PHP_SAPI; use const PHP_SAPI;
@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad
return (new ConfigAggregator\ConfigAggregator([ return (new ConfigAggregator\ConfigAggregator([
! $isTestEnv ! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values()) ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]), : new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class, Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class, Mezzio\Router\ConfigProvider::class,

View file

@ -6,3 +6,10 @@ namespace ShlinkioTest\Shlink;
const API_TESTS_HOST = '127.0.0.1'; const API_TESTS_HOST = '127.0.0.1';
const API_TESTS_PORT = 9999; const API_TESTS_PORT = 9999;
const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20230103105343 extends AbstractMigration
{
private const TABLE_NAME = 'device_long_urls';
public function up(Schema $schema): void
{
$this->skipIf($schema->hasTable(self::TABLE_NAME));
$table = $schema->createTable(self::TABLE_NAME);
$table->addColumn('id', Types::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addColumn('device_type', Types::STRING, ['length' => 255]);
$table->addColumn('long_url', Types::STRING, ['length' => 2048]);
$table->addColumn('short_url_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url');
}
public function down(Schema $schema): void
{
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
$schema->dropTable(self::TABLE_NAME);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View file

@ -111,6 +111,9 @@
"type": "string", "type": "string",
"description": "The original long URL." "description": "The original long URL."
}, },
"deviceLongUrls": {
"$ref": "#/components/schemas/DeviceLongUrls"
},
"dateCreated": { "dateCreated": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
@ -152,6 +155,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": "https://store.steampowered.com/android",
"ios": "https://store.steampowered.com/ios",
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 328, "total": 328,
@ -215,6 +223,24 @@
} }
} }
}, },
"DeviceLongUrls": {
"type": "object",
"required": ["android", "ios", "desktop"],
"properties": {
"android": {
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
"type": "string"
},
"ios": {
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
"type": "string"
},
"desktop": {
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
"type": "string"
}
}
},
"Visit": { "Visit": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -0,0 +1,20 @@
{
"type": "object",
"properties": {
"android": {
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
"type": "string",
"nullable": false
},
"ios": {
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
"type": "string",
"nullable": false
},
"desktop": {
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
"type": "string",
"nullable": false
}
}
}

View file

@ -0,0 +1,17 @@
{
"type": "object",
"allOf": [{
"$ref": "./DeviceLongUrls.json"
}],
"properties": {
"android": {
"nullable": true
},
"ios": {
"nullable": true
},
"desktop": {
"nullable": true
}
}
}

View file

@ -0,0 +1,7 @@
{
"type": "object",
"required": ["android", "ios", "desktop"],
"allOf": [{
"$ref": "./DeviceLongUrlsEdit.json"
}]
}

View file

@ -4,6 +4,7 @@
"shortCode", "shortCode",
"shortUrl", "shortUrl",
"longUrl", "longUrl",
"deviceLongUrls",
"dateCreated", "dateCreated",
"visitsCount", "visitsCount",
"visitsSummary", "visitsSummary",
@ -27,6 +28,9 @@
"type": "string", "type": "string",
"description": "The original long URL." "description": "The original long URL."
}, },
"deviceLongUrls": {
"$ref": "./DeviceLongUrlsResp.json"
},
"dateCreated": { "dateCreated": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",

View file

@ -5,6 +5,9 @@
"description": "The long URL this short URL will redirect to", "description": "The long URL this short URL will redirect to",
"type": "string" "type": "string"
}, },
"deviceLongUrls": {
"$ref": "./DeviceLongUrlsEdit.json"
},
"validSince": { "validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid", "description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string", "type": "string",

View file

@ -163,6 +163,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 328, "total": 328,
@ -186,6 +191,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": null,
"ios": "https://shlink.io/ios",
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,
@ -208,6 +218,11 @@
"shortCode": "123bA", "shortCode": "123bA",
"shortUrl": "https://example.com/123bA", "shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com", "longUrl": "https://www.google.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2015-10-01T20:34:16+02:00", "dateCreated": "2015-10-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 25, "total": 25,
@ -281,6 +296,9 @@
"type": "object", "type": "object",
"required": ["longUrl"], "required": ["longUrl"],
"properties": { "properties": {
"deviceLongUrls": {
"$ref": "../definitions/DeviceLongUrls.json"
},
"customSlug": { "customSlug": {
"description": "A unique custom slug to be used instead of the generated short code", "description": "A unique custom slug to be used instead of the generated short code",
"type": "string" "type": "string"
@ -320,6 +338,11 @@
"shortCode": "12C18", "shortCode": "12C18",
"shortUrl": "https://s.test/12C18", "shortUrl": "https://s.test/12C18",
"longUrl": "https://store.steampowered.com", "longUrl": "https://store.steampowered.com",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 0, "total": 0,

View file

@ -1,11 +1,12 @@
{ {
"get": { "get": {
"operationId": "shortenUrl", "operationId": "shortenUrl",
"deprecated": true,
"tags": [ "tags": [
"Short URLs" "Short URLs"
], ],
"summary": "Create a short URL", "summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations.", "description": "**[Deprecated]** Use [Create short URL](#/Short%20URLs/createShortUrl) instead",
"parameters": [ "parameters": [
{ {
"$ref": "../parameters/version.json" "$ref": "../parameters/version.json"
@ -52,6 +53,11 @@
}, },
"example": { "example": {
"longUrl": "https://github.com/shlinkio/shlink", "longUrl": "https://github.com/shlinkio/shlink",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"shortUrl": "https://s.test/abc123", "shortUrl": "https://s.test/abc123",
"shortCode": "abc123", "shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00", "dateCreated": "2016-08-21T20:34:16+02:00",

View file

@ -40,6 +40,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": null,
"ios": null,
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,
@ -162,6 +167,11 @@
"shortCode": "12Kb3", "shortCode": "12Kb3",
"shortUrl": "https://s.test/12Kb3", "shortUrl": "https://s.test/12Kb3",
"longUrl": "https://shlink.io", "longUrl": "https://shlink.io",
"deviceLongUrls": {
"android": "https://shlink.io/android",
"ios": null,
"desktop": null
},
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsSummary": { "visitsSummary": {
"total": 1029, "total": 1029,

View file

@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase
/** @test */ /** @test */
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
); );

View file

@ -48,7 +48,7 @@ class CreateShortUrlCommandTest extends TestCase
/** @test */ /** @test */
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl); $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url', 'stringified_short_url',
@ -98,11 +98,10 @@ class CreateShortUrlCommandTest extends TestCase
/** @test */ /** @test */
public function properlyProcessesProvidedTags(): void public function properlyProcessesProvidedTags(): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->with( $this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) { $this->callback(function (ShortUrlCreation $creation) {
$tags = $meta->getTags(); Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
return true; return true;
}), }),
)->willReturn($shortUrl); )->willReturn($shortUrl);
@ -128,10 +127,10 @@ class CreateShortUrlCommandTest extends TestCase
{ {
$this->urlShortener->expects($this->once())->method('shorten')->with( $this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
Assert::assertEquals($expectedDomain, $meta->getDomain()); Assert::assertEquals($expectedDomain, $meta->domain);
return true; return true;
}), }),
)->willReturn(ShortUrl::createEmpty()); )->willReturn(ShortUrl::createFake());
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar'; $input['longUrl'] = 'http://domain.com/foo/bar';
@ -154,7 +153,7 @@ class CreateShortUrlCommandTest extends TestCase
*/ */
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->with( $this->urlShortener->expects($this->once())->method('shorten')->with(
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());

View file

@ -94,7 +94,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
/** @test */ /** @test */
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
); );
$shortCode = 'abc123'; $shortCode = 'abc123';

View file

@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase
/** @test */ /** @test */
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
); );

View file

@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
/** @test */ /** @test */
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
); );

View file

@ -66,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase
bool $expectWarningPrint, bool $expectWarningPrint,
array $args, array $args,
): void { ): void {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
@ -113,7 +113,7 @@ class LocateVisitsCommandTest extends TestCase
*/ */
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
@ -140,7 +140,7 @@ class LocateVisitsCommandTest extends TestCase
/** @test */ /** @test */
public function errorWhileLocatingIpIsDisplayed(): void public function errorWhileLocatingIpIsDisplayed(): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
$location = VisitLocation::fromGeolocation(Location::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance());
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true);

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Core\Model\DeviceType;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(determineTableName('device_long_urls', $emConfig));
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
(new FieldBuilder($builder, [
'fieldName' => 'deviceType',
'type' => Types::STRING,
'enumType' => DeviceType::class,
]))->columnName('device_type')
->length(255)
->build();
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
->columnName('long_url')
->length(2048)
->build();
$builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
->build();
};

View file

@ -24,7 +24,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build(); ->build();
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
->columnName('original_url') ->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯
->length(2048) ->length(2048)
->build(); ->build();
@ -67,6 +67,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->fetchExtraLazy() ->fetchExtraLazy()
->build(); ->build();
$builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class)
->mappedBy('shortUrl')
->cascadePersist()
->orphanRemoval()
->setIndexBy('deviceType')
->build();
$builder->createManyToMany('tags', Tag\Entity\Tag::class) $builder->createManyToMany('tags', Tag\Entity\Tag::class)
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core; namespace Shlinkio\Shlink\Core;
use BackedEnum;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosInterface; use Cake\Chronos\ChronosInterface;
use DateTimeInterface; use DateTimeInterface;
@ -16,6 +17,7 @@ use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use function date_default_timezone_get; use function date_default_timezone_get;
use function Functional\map;
use function Functional\reduce_left; use function Functional\reduce_left;
use function is_array; use function is_array;
use function print_r; use function print_r;
@ -159,3 +161,19 @@ function toProblemDetailsType(string $errorCode): string
{ {
return sprintf('https://shlink.io/api/error/%s', $errorCode); return sprintf('https://shlink.io/api/error/%s', $errorCode);
} }
/**
* @param class-string<BackedEnum> $enum
* @return string[]
*/
function enumValues(string $enum): array
{
static $cache;
if ($cache === null) {
$cache = [];
}
return $cache[$enum] ?? (
$cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value)
);
}

View file

@ -18,15 +18,15 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa
public function __construct( public function __construct(
ShortUrlResolverInterface $urlResolver, ShortUrlResolverInterface $urlResolver,
RequestTrackerInterface $requestTracker, RequestTrackerInterface $requestTracker,
private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder,
private RedirectResponseHelperInterface $redirectResponseHelper, private readonly RedirectResponseHelperInterface $redirectResponseHelper,
) { ) {
parent::__construct($urlResolver, $requestTracker); parent::__construct($urlResolver, $requestTracker);
} }
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
{ {
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams()); $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl); return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
} }
} }

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config; namespace Shlinkio\Shlink\Core\Config;
use function Functional\map;
use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\env;
enum EnvVars: string enum EnvVars: string
@ -77,13 +76,4 @@ enum EnvVars: string
{ {
return $this->loadFromEnv() !== null; return $this->loadFromEnv() !== null;
} }
/**
* @return string[]
*/
public static function values(): array
{
static $values;
return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value));
}
} }

View file

@ -51,7 +51,7 @@ class DomainService implements DomainServiceInterface
$repo = $this->em->getRepository(Domain::class); $repo = $this->em->getRepository(Domain::class);
$groups = group( $groups = group(
$repo->findDomains($apiKey), $repo->findDomains($apiKey),
fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains', fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains',
); );
return [first($groups['default'] ?? []), $groups['domains'] ?? []]; return [first($groups['default'] ?? []), $groups['domains'] ?? []];

View file

@ -15,7 +15,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
private ?string $regular404Redirect = null; private ?string $regular404Redirect = null;
private ?string $invalidShortUrlRedirect = null; private ?string $invalidShortUrlRedirect = null;
private function __construct(private string $authority) private function __construct(public readonly string $authority)
{ {
} }
@ -24,14 +24,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
return new self($authority); return new self($authority);
} }
public function getAuthority(): string
{
return $this->authority;
}
public function jsonSerialize(): string public function jsonSerialize(): string
{ {
return $this->getAuthority(); return $this->authority;
} }
public function invalidShortUrlRedirect(): ?string public function invalidShortUrlRedirect(): ?string

View file

@ -20,7 +20,7 @@ final class DomainItem implements JsonSerializable
public static function forNonDefaultDomain(Domain $domain): self public static function forNonDefaultDomain(Domain $domain): self
{ {
return new self($domain->getAuthority(), $domain, false); return new self($domain->authority, $domain, false);
} }
public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self

View file

@ -0,0 +1,28 @@
<?php
namespace Shlinkio\Shlink\Core\Model;
use Detection\MobileDetect;
enum DeviceType: string
{
case ANDROID = 'android';
case IOS = 'ios';
case DESKTOP = 'desktop';
public static function matchFromUserAgent(string $userAgent): ?self
{
$detect = new MobileDetect(null, $userAgent); // @phpstan-ignore-line
return match (true) {
// $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only
// $detect->is('iOS') && ! $detect->isTablet() => self::IOS, // TODO To detect iPhone only
// $detect->is('androidOS') && $detect->isTablet() => self::ANDROID, // TODO To detect Android tablets
// $detect->is('androidOS') && ! $detect->isTablet() => self::ANDROID, // TODO To detect Android phones
$detect->is('iOS') => self::IOS, // Detects both iPhone and iPad
$detect->is('androidOS') => self::ANDROID, // Detects both android phones and android tablets
! $detect->isMobile() && ! $detect->isTablet() => self::DESKTOP,
default => null,
};
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair;
class DeviceLongUrl extends AbstractEntity
{
private function __construct(
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
public readonly DeviceType $deviceType,
private string $longUrl,
) {
}
public static function fromShortUrlAndPair(ShortUrl $shortUrl, DeviceLongUrlPair $pair): self
{
return new self($shortUrl, $pair->deviceType, $pair->longUrl);
}
public function longUrl(): string
{
return $this->longUrl;
}
public function updateLongUrl(string $longUrl): void
{
$this->longUrl = $longUrl;
}
}

View file

@ -12,6 +12,8 @@ use Doctrine\Common\Collections\Selectable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
@ -23,7 +25,10 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function array_fill_keys;
use function count; use function count;
use function Functional\map;
use function Shlinkio\Shlink\Core\enumValues;
use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate;
@ -35,6 +40,8 @@ class ShortUrl extends AbstractEntity
private Chronos $dateCreated; private Chronos $dateCreated;
/** @var Collection<int, Visit> */ /** @var Collection<int, Visit> */
private Collection $visits; private Collection $visits;
/** @var Collection<string, DeviceLongUrl> */
private Collection $deviceLongUrls;
/** @var Collection<int, Tag> */ /** @var Collection<int, Tag> */
private Collection $tags; private Collection $tags;
private ?Chronos $validSince = null; private ?Chronos $validSince = null;
@ -55,11 +62,14 @@ class ShortUrl extends AbstractEntity
{ {
} }
public static function createEmpty(): self public static function createFake(): self
{ {
return self::create(ShortUrlCreation::createEmpty()); return self::withLongUrl('foo');
} }
/**
* @param non-empty-string $longUrl
*/
public static function withLongUrl(string $longUrl): self public static function withLongUrl(string $longUrl): self
{ {
return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
@ -75,19 +85,23 @@ class ShortUrl extends AbstractEntity
$instance->longUrl = $creation->getLongUrl(); $instance->longUrl = $creation->getLongUrl();
$instance->dateCreated = Chronos::now(); $instance->dateCreated = Chronos::now();
$instance->visits = new ArrayCollection(); $instance->visits = new ArrayCollection();
$instance->tags = $relationResolver->resolveTags($creation->getTags()); $instance->deviceLongUrls = new ArrayCollection(map(
$instance->validSince = $creation->getValidSince(); $creation->deviceLongUrls,
$instance->validUntil = $creation->getValidUntil(); fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair),
$instance->maxVisits = $creation->getMaxVisits(); ));
$instance->tags = $relationResolver->resolveTags($creation->tags);
$instance->validSince = $creation->validSince;
$instance->validUntil = $creation->validUntil;
$instance->maxVisits = $creation->maxVisits;
$instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->customSlugWasProvided = $creation->hasCustomSlug();
$instance->shortCodeLength = $creation->getShortCodeLength(); $instance->shortCodeLength = $creation->shortCodeLength;
$instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); $instance->shortCode = $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength);
$instance->domain = $relationResolver->resolveDomain($creation->getDomain()); $instance->domain = $relationResolver->resolveDomain($creation->domain);
$instance->authorApiKey = $creation->getApiKey(); $instance->authorApiKey = $creation->apiKey;
$instance->title = $creation->getTitle(); $instance->title = $creation->title;
$instance->titleWasAutoResolved = $creation->titleWasAutoResolved(); $instance->titleWasAutoResolved = $creation->titleWasAutoResolved;
$instance->crawlable = $creation->isCrawlable(); $instance->crawlable = $creation->crawlable;
$instance->forwardQuery = $creation->forwardQuery(); $instance->forwardQuery = $creation->forwardQuery;
return $instance; return $instance;
} }
@ -120,11 +134,68 @@ class ShortUrl extends AbstractEntity
return $instance; return $instance;
} }
public function update(
ShortUrlEdition $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null,
): void {
if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince;
}
if ($shortUrlEdit->validUntilWasProvided()) {
$this->validUntil = $shortUrlEdit->validUntil;
}
if ($shortUrlEdit->maxVisitsWasProvided()) {
$this->maxVisits = $shortUrlEdit->maxVisits;
}
if ($shortUrlEdit->longUrlWasProvided()) {
$this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl;
}
if ($shortUrlEdit->tagsWereProvided()) {
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags);
}
if ($shortUrlEdit->crawlableWasProvided()) {
$this->crawlable = $shortUrlEdit->crawlable;
}
if (
$this->title === null
|| $shortUrlEdit->titleWasProvided()
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
) {
$this->title = $shortUrlEdit->title;
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
}
if ($shortUrlEdit->forwardQueryWasProvided()) {
$this->forwardQuery = $shortUrlEdit->forwardQuery;
}
// Update device long URLs, removing, editing or creating where appropriate
foreach ($shortUrlEdit->devicesToRemove as $deviceType) {
$this->deviceLongUrls->remove($deviceType->value);
}
foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) {
$key = $deviceLongUrlPair->deviceType->value;
$deviceLongUrl = $this->deviceLongUrls->get($key);
if ($deviceLongUrl !== null) {
$deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl);
} else {
$this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair));
}
}
}
public function getLongUrl(): string public function getLongUrl(): string
{ {
return $this->longUrl; return $this->longUrl;
} }
public function longUrlForDevice(?DeviceType $deviceType): string
{
$deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value);
return $deviceLongUrl?->longUrl() ?? $this->longUrl;
}
public function getShortCode(): string public function getShortCode(): string
{ {
return $this->shortCode; return $this->shortCode;
@ -218,42 +289,6 @@ class ShortUrl extends AbstractEntity
return $this->forwardQuery; return $this->forwardQuery;
} }
public function update(
ShortUrlEdition $shortUrlEdit,
?ShortUrlRelationResolverInterface $relationResolver = null,
): void {
if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince();
}
if ($shortUrlEdit->validUntilWasProvided()) {
$this->validUntil = $shortUrlEdit->validUntil();
}
if ($shortUrlEdit->maxVisitsWasProvided()) {
$this->maxVisits = $shortUrlEdit->maxVisits();
}
if ($shortUrlEdit->longUrlWasProvided()) {
$this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl;
}
if ($shortUrlEdit->tagsWereProvided()) {
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags());
}
if ($shortUrlEdit->crawlableWasProvided()) {
$this->crawlable = $shortUrlEdit->crawlable();
}
if (
$this->title === null
|| $shortUrlEdit->titleWasProvided()
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
) {
$this->title = $shortUrlEdit->title();
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
}
if ($shortUrlEdit->forwardQueryWasProvided()) {
$this->forwardQuery = $shortUrlEdit->forwardQuery();
}
}
/** /**
* @throws ShortCodeCannotBeRegeneratedException * @throws ShortCodeCannotBeRegeneratedException
*/ */
@ -292,4 +327,14 @@ class ShortUrl extends AbstractEntity
return true; return true;
} }
public function deviceLongUrls(): array
{
$data = array_fill_keys(enumValues(DeviceType::class), null);
foreach ($this->deviceLongUrls as $deviceUrl) {
$data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl();
}
return $data;
}
} }

View file

@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Query;
use Laminas\Stdlib\ArrayUtils; use Laminas\Stdlib\ArrayUtils;
use League\Uri\Uri; use League\Uri\Uri;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -14,13 +16,18 @@ use function sprintf;
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
{ {
public function __construct(private TrackingOptions $trackingOptions) public function __construct(private readonly TrackingOptions $trackingOptions)
{ {
} }
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string public function buildShortUrlRedirect(
{ ShortUrl $shortUrl,
$uri = Uri::createFromString($shortUrl->getLongUrl()); ServerRequestInterface $request,
?string $extraPath = null,
): string {
$currentQuery = $request->getQueryParams();
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
$uri = Uri::createFromString($shortUrl->longUrlForDevice($device));
$shouldForwardQuery = $shortUrl->forwardQuery(); $shouldForwardQuery = $shortUrl->forwardQuery();
return $uri return $uri

View file

@ -4,9 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper; namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
interface ShortUrlRedirectionBuilderInterface interface ShortUrlRedirectionBuilderInterface
{ {
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string; public function buildShortUrlRedirect(
ShortUrl $shortUrl,
ServerRequestInterface $request,
?string $extraPath = null,
): string;
} }

View file

@ -11,7 +11,7 @@ use function sprintf;
class ShortUrlStringifier implements ShortUrlStringifierInterface class ShortUrlStringifier implements ShortUrlStringifierInterface
{ {
public function __construct(private array $domainConfig, private string $basePath = '') public function __construct(private readonly array $domainConfig, private readonly string $basePath = '')
{ {
} }
@ -28,6 +28,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface
private function resolveDomain(ShortUrl $shortUrl): string private function resolveDomain(ShortUrl $shortUrl): string
{ {
return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? ''; return $shortUrl->getDomain()?->authority ?? $this->domainConfig['hostname'] ?? '';
} }
} }

View file

@ -4,14 +4,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Helper; namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
{ {
public function __construct(private UrlValidatorInterface $urlValidator) public function __construct(private readonly UrlValidatorInterface $urlValidator)
{ {
} }
/**
* @template T of TitleResolutionModelInterface
* @param T $data
* @return T
* @throws InvalidUrlException
*/
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface
{ {
if ($data->hasTitle()) { if ($data->hasTitle()) {

View file

@ -9,6 +9,9 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
interface ShortUrlTitleResolutionHelperInterface interface ShortUrlTitleResolutionHelperInterface
{ {
/** /**
* @template T of TitleResolutionModelInterface
* @param T $data
* @return T
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface; public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface;

View file

@ -12,5 +12,5 @@ interface TitleResolutionModelInterface
public function doValidateUrl(): bool; public function doValidateUrl(): bool;
public function withResolvedTitle(string $title): self; public function withResolvedTitle(string $title): static;
} }

View file

@ -68,7 +68,6 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface
int $shortCodeSegments = 1, int $shortCodeSegments = 1,
): ResponseInterface { ): ResponseInterface {
$uri = $request->getUri(); $uri = $request->getUri();
$query = $request->getQueryParams();
[$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments); [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority());
@ -76,7 +75,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
$this->requestTracker->trackIfApplicable($shortUrl, $request); $this->requestTracker->trackIfApplicable($shortUrl, $request);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl); return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
} catch (ShortUrlNotFoundException) { } catch (ShortUrlNotFoundException) {
if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) {

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Shlinkio\Shlink\Core\Model\DeviceType;
use function array_values;
use function Functional\group;
use function Functional\map;
use function trim;
final class DeviceLongUrlPair
{
private function __construct(public readonly DeviceType $deviceType, public readonly string $longUrl)
{
}
public static function fromRawTypeAndLongUrl(string $type, string $longUrl): self
{
return new self(DeviceType::from($type), trim($longUrl));
}
/**
* Returns an array with two values.
* * The first one is a list of mapped instances for those entries in the map with non-null value
* * The second is a list of DeviceTypes which have been provided with value null
*
* @param array<string, string> $map
* @return array{array<string, self>, DeviceType[]}
*/
public static function fromMapToChangeSet(array $map): array
{
$typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep');
$deviceTypesToRemove = array_values(map(
$typesWithNullUrl['remove'] ?? [],
static fn ($_, string $deviceType) => DeviceType::from($deviceType),
));
$pairsToKeep = map(
$typesWithNullUrl['keep'] ?? [],
fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl),
);
return [$pairsToKeep, $deviceTypesToRemove];
}
}

View file

@ -3,7 +3,6 @@
namespace Shlinkio\Shlink\Core\ShortUrl\Model; namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\contains; use function Functional\contains;
use function Functional\map;
enum OrderableField: string enum OrderableField: string
{ {
@ -14,14 +13,6 @@ enum OrderableField: string
case VISITS = 'visits'; case VISITS = 'visits';
case NON_BOT_VISITS = 'nonBotVisits'; case NON_BOT_VISITS = 'nonBotVisits';
/**
* @return string[]
*/
public static function values(): array
{
return map(self::cases(), static fn (OrderableField $field) => $field->value);
}
public static function isBasicField(string $value): bool public static function isBasicField(string $value): bool
{ {
return contains( return contains(

View file

@ -19,72 +19,86 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlCreation implements TitleResolutionModelInterface final class ShortUrlCreation implements TitleResolutionModelInterface
{ {
private string $longUrl; /**
private ?Chronos $validSince = null; * @param string[] $tags
private ?Chronos $validUntil = null; * @param DeviceLongUrlPair[] $deviceLongUrls
private ?string $customSlug = null; */
private ?int $maxVisits = null; private function __construct(
private ?bool $findIfExists = null; public readonly string $longUrl,
private ?string $domain = null; public readonly array $deviceLongUrls = [],
private int $shortCodeLength = 5; public readonly ?Chronos $validSince = null,
private bool $validateUrl = false; public readonly ?Chronos $validUntil = null,
private ?ApiKey $apiKey = null; public readonly ?string $customSlug = null,
private array $tags = []; public readonly ?int $maxVisits = null,
private ?string $title = null; public readonly bool $findIfExists = false,
private bool $titleWasAutoResolved = false; public readonly ?string $domain = null,
private bool $crawlable = false; public readonly int $shortCodeLength = 5,
private bool $forwardQuery = true; public readonly bool $validateUrl = false,
public readonly ?ApiKey $apiKey = null,
private function __construct() public readonly array $tags = [],
{ public readonly ?string $title = null,
} public readonly bool $titleWasAutoResolved = false,
public readonly bool $crawlable = false,
public static function createEmpty(): self public readonly bool $forwardQuery = true,
{ ) {
$instance = new self();
$instance->longUrl = '';
return $instance;
} }
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data): self public static function fromRawData(array $data): self
{
$instance = new self();
$instance->validateAndInit($data);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $data): void
{ {
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); [$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet(
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); );
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); return new self(
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; deviceLongUrls: $deviceLongUrls,
$this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN); validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
$this->shortCodeLength = getOptionalIntFromInputFilter( validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
$inputFilter, customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG),
ShortUrlInputFilter::SHORT_CODE_LENGTH, maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
) ?? DEFAULT_SHORT_CODES_LENGTH; findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false,
$this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN),
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); shortCodeLength: getOptionalIntFromInputFilter(
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); $inputFilter,
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); ShortUrlInputFilter::SHORT_CODE_LENGTH,
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; ) ?? DEFAULT_SHORT_CODES_LENGTH,
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY),
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
);
}
public function withResolvedTitle(string $title): static
{
return new self(
longUrl: $this->longUrl,
deviceLongUrls: $this->deviceLongUrls,
validSince: $this->validSince,
validUntil: $this->validUntil,
customSlug: $this->customSlug,
maxVisits: $this->maxVisits,
findIfExists: $this->findIfExists,
domain: $this->domain,
shortCodeLength: $this->shortCodeLength,
validateUrl: $this->validateUrl,
apiKey: $this->apiKey,
tags: $this->tags,
title: $title,
titleWasAutoResolved: true,
crawlable: $this->crawlable,
forwardQuery: $this->forwardQuery,
);
} }
public function getLongUrl(): string public function getLongUrl(): string
@ -92,115 +106,38 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
return $this->longUrl; return $this->longUrl;
} }
public function getValidSince(): ?Chronos
{
return $this->validSince;
}
public function hasValidSince(): bool public function hasValidSince(): bool
{ {
return $this->validSince !== null; return $this->validSince !== null;
} }
public function getValidUntil(): ?Chronos
{
return $this->validUntil;
}
public function hasValidUntil(): bool public function hasValidUntil(): bool
{ {
return $this->validUntil !== null; return $this->validUntil !== null;
} }
public function getCustomSlug(): ?string
{
return $this->customSlug;
}
public function hasCustomSlug(): bool public function hasCustomSlug(): bool
{ {
return $this->customSlug !== null; return $this->customSlug !== null;
} }
public function getMaxVisits(): ?int
{
return $this->maxVisits;
}
public function hasMaxVisits(): bool public function hasMaxVisits(): bool
{ {
return $this->maxVisits !== null; return $this->maxVisits !== null;
} }
public function findIfExists(): bool
{
return (bool) $this->findIfExists;
}
public function hasDomain(): bool public function hasDomain(): bool
{ {
return $this->domain !== null; return $this->domain !== null;
} }
public function getDomain(): ?string
{
return $this->domain;
}
public function getShortCodeLength(): int
{
return $this->shortCodeLength;
}
public function doValidateUrl(): bool public function doValidateUrl(): bool
{ {
return $this->validateUrl; return $this->validateUrl;
} }
public function getApiKey(): ?ApiKey
{
return $this->apiKey;
}
/**
* @return string[]
*/
public function getTags(): array
{
return $this->tags;
}
public function getTitle(): ?string
{
return $this->title;
}
public function hasTitle(): bool public function hasTitle(): bool
{ {
return $this->title !== null; return $this->title !== null;
} }
public function titleWasAutoResolved(): bool
{
return $this->titleWasAutoResolved;
}
public function withResolvedTitle(string $title): self
{
$copy = clone $this;
$copy->title = $title;
$copy->titleWasAutoResolved = true;
return $copy;
}
public function isCrawlable(): bool
{
return $this->crawlable;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
} }

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
@ -16,77 +17,101 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface final class ShortUrlEdition implements TitleResolutionModelInterface
{ {
private bool $longUrlPropWasProvided = false; /**
private ?string $longUrl = null; * @param string[] $tags
private bool $validSincePropWasProvided = false; * @param DeviceLongUrlPair[] $deviceLongUrls
private ?Chronos $validSince = null; * @param DeviceType[] $devicesToRemove
private bool $validUntilPropWasProvided = false; */
private ?Chronos $validUntil = null; private function __construct(
private bool $maxVisitsPropWasProvided = false; private readonly bool $longUrlPropWasProvided = false,
private ?int $maxVisits = null; public readonly ?string $longUrl = null,
private bool $tagsPropWasProvided = false; public readonly array $deviceLongUrls = [],
private array $tags = []; public readonly array $devicesToRemove = [],
private bool $titlePropWasProvided = false; private readonly bool $validSincePropWasProvided = false,
private ?string $title = null; public readonly ?Chronos $validSince = null,
private bool $titleWasAutoResolved = false; private readonly bool $validUntilPropWasProvided = false,
private bool $validateUrl = false; public readonly ?Chronos $validUntil = null,
private bool $crawlablePropWasProvided = false; private readonly bool $maxVisitsPropWasProvided = false,
private bool $crawlable = false; public readonly ?int $maxVisits = null,
private bool $forwardQueryPropWasProvided = false; private readonly bool $tagsPropWasProvided = false,
private bool $forwardQuery = true; public readonly array $tags = [],
private readonly bool $titlePropWasProvided = false,
private function __construct() public readonly ?string $title = null,
{ public readonly bool $titleWasAutoResolved = false,
public readonly bool $validateUrl = false,
private readonly bool $crawlablePropWasProvided = false,
public readonly bool $crawlable = false,
private readonly bool $forwardQueryPropWasProvided = false,
public readonly bool $forwardQuery = true,
) {
} }
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data): self public static function fromRawData(array $data): self
{
$instance = new self();
$instance->validateAndInit($data);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $data): void
{ {
$inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
$this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data); [$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet(
$this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
$this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); );
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data);
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data);
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); return new self(
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data),
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); deviceLongUrls: $deviceLongUrls,
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; devicesToRemove: $devicesToRemove,
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data),
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data),
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
maxVisitsPropWasProvided: array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data),
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
tagsPropWasProvided: array_key_exists(ShortUrlInputFilter::TAGS, $data),
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data),
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data),
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data),
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
);
} }
public function longUrl(): ?string public function withResolvedTitle(string $title): static
{ {
return $this->longUrl; return new self(
longUrlPropWasProvided: $this->longUrlPropWasProvided,
longUrl: $this->longUrl,
deviceLongUrls: $this->deviceLongUrls,
devicesToRemove: $this->devicesToRemove,
validSincePropWasProvided: $this->validSincePropWasProvided,
validSince: $this->validSince,
validUntilPropWasProvided: $this->validUntilPropWasProvided,
validUntil: $this->validUntil,
maxVisitsPropWasProvided: $this->maxVisitsPropWasProvided,
maxVisits: $this->maxVisits,
tagsPropWasProvided: $this->tagsPropWasProvided,
tags: $this->tags,
titlePropWasProvided: $this->titlePropWasProvided,
title: $title,
titleWasAutoResolved: true,
validateUrl: $this->validateUrl,
crawlablePropWasProvided: $this->crawlablePropWasProvided,
crawlable: $this->crawlable,
forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided,
forwardQuery: $this->forwardQuery,
);
} }
public function getLongUrl(): string public function getLongUrl(): string
{ {
return $this->longUrl() ?? ''; return $this->longUrl ?? '';
} }
public function longUrlWasProvided(): bool public function longUrlWasProvided(): bool
@ -94,54 +119,26 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
return $this->longUrlPropWasProvided && $this->longUrl !== null; return $this->longUrlPropWasProvided && $this->longUrl !== null;
} }
public function validSince(): ?Chronos
{
return $this->validSince;
}
public function validSinceWasProvided(): bool public function validSinceWasProvided(): bool
{ {
return $this->validSincePropWasProvided; return $this->validSincePropWasProvided;
} }
public function validUntil(): ?Chronos
{
return $this->validUntil;
}
public function validUntilWasProvided(): bool public function validUntilWasProvided(): bool
{ {
return $this->validUntilPropWasProvided; return $this->validUntilPropWasProvided;
} }
public function maxVisits(): ?int
{
return $this->maxVisits;
}
public function maxVisitsWasProvided(): bool public function maxVisitsWasProvided(): bool
{ {
return $this->maxVisitsPropWasProvided; return $this->maxVisitsPropWasProvided;
} }
/**
* @return string[]
*/
public function tags(): array
{
return $this->tags;
}
public function tagsWereProvided(): bool public function tagsWereProvided(): bool
{ {
return $this->tagsPropWasProvided; return $this->tagsPropWasProvided;
} }
public function title(): ?string
{
return $this->title;
}
public function titleWasProvided(): bool public function titleWasProvided(): bool
{ {
return $this->titlePropWasProvided; return $this->titlePropWasProvided;
@ -157,35 +154,16 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
return $this->titleWasAutoResolved; return $this->titleWasAutoResolved;
} }
public function withResolvedTitle(string $title): self
{
$copy = clone $this;
$copy->title = $title;
$copy->titleWasAutoResolved = true;
return $copy;
}
public function doValidateUrl(): bool public function doValidateUrl(): bool
{ {
return $this->validateUrl; return $this->validateUrl;
} }
public function crawlable(): bool
{
return $this->crawlable;
}
public function crawlableWasProvided(): bool public function crawlableWasProvided(): bool
{ {
return $this->crawlablePropWasProvided; return $this->crawlablePropWasProvided;
} }
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
public function forwardQueryWasProvided(): bool public function forwardQueryWasProvided(): bool
{ {
return $this->forwardQueryPropWasProvided; return $this->forwardQueryPropWasProvided;

View file

@ -45,7 +45,7 @@ final class ShortUrlIdentifier
public static function fromShortUrl(ShortUrl $shortUrl): self public static function fromShortUrl(ShortUrl $shortUrl): self
{ {
$domain = $shortUrl->getDomain(); $domain = $shortUrl->getDomain();
$domainAuthority = $domain?->getAuthority(); $domainAuthority = $domain?->authority;
return new self($shortUrl->getShortCode(), $domainAuthority); return new self($shortUrl->getShortCode(), $domainAuthority);
} }

View file

@ -4,15 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model; namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\map;
enum TagsMode: string enum TagsMode: string
{ {
case ANY = 'any'; case ANY = 'any';
case ALL = 'all'; case ALL = 'all';
public static function values(): array
{
return map(self::cases(), static fn (TagsMode $mode) => $mode->value);
}
} }

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\Validator\AbstractValidator;
use Laminas\Validator\ValidatorInterface;
use Shlinkio\Shlink\Core\Model\DeviceType;
use function array_keys;
use function array_values;
use function Functional\contains;
use function Functional\every;
use function is_array;
use function Shlinkio\Shlink\Core\enumValues;
class DeviceLongUrlsValidator extends AbstractValidator
{
private const NOT_ARRAY = 'NOT_ARRAY';
private const INVALID_DEVICE = 'INVALID_DEVICE';
private const INVALID_LONG_URL = 'INVALID_LONG_URL';
protected array $messageTemplates = [
self::NOT_ARRAY => 'Provided value is not an array.',
self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.',
self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.',
];
public function __construct(private readonly ValidatorInterface $longUrlValidators)
{
parent::__construct();
}
public function isValid(mixed $value): bool
{
if (! is_array($value)) {
$this->error(self::NOT_ARRAY);
return false;
}
$validValues = enumValues(DeviceType::class);
$keys = array_keys($value);
if (! every($keys, static fn ($key) => contains($validValues, $key))) {
$this->error(self::INVALID_DEVICE);
return false;
}
$longUrls = array_values($value);
$result = every($longUrls, $this->longUrlValidators->isValid(...));
if (! $result) {
$this->error(self::INVALID_LONG_URL);
}
return $result;
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use DateTime; use DateTimeInterface;
use Laminas\Filter; use Laminas\Filter;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Laminas\Validator; use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Common\Validation;
@ -32,6 +31,7 @@ class ShortUrlInputFilter extends InputFilter
public const DOMAIN = 'domain'; public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const LONG_URL = 'longUrl'; public const LONG_URL = 'longUrl';
public const DEVICE_LONG_URLS = 'deviceLongUrls';
public const VALIDATE_URL = 'validateUrl'; public const VALIDATE_URL = 'validateUrl';
public const API_KEY = 'apiKey'; public const API_KEY = 'apiKey';
public const TAGS = 'tags'; public const TAGS = 'tags';
@ -41,6 +41,7 @@ class ShortUrlInputFilter extends InputFilter
private function __construct(array $data, bool $requireLongUrl) private function __construct(array $data, bool $requireLongUrl)
{ {
// FIXME The multi-segment slug option should be injected
$this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false);
$this->setData($data); $this->setData($data);
} }
@ -57,26 +58,40 @@ class ShortUrlInputFilter extends InputFilter
private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void
{ {
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); $longUrlNotEmptyCommonOptions = [
$longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::OBJECT, Validator\NotEmpty::OBJECT,
Validator\NotEmpty::SPACE, Validator\NotEmpty::SPACE,
Validator\NotEmpty::NULL,
Validator\NotEmpty::EMPTY_ARRAY, Validator\NotEmpty::EMPTY_ARRAY,
Validator\NotEmpty::BOOLEAN, Validator\NotEmpty::BOOLEAN,
Validator\NotEmpty::STRING,
];
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
$longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([
...$longUrlNotEmptyCommonOptions,
Validator\NotEmpty::NULL,
])); ]));
$this->add($longUrlInput); $this->add($longUrlInput);
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
$deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator(new Validator\NotEmpty([
...$longUrlNotEmptyCommonOptions,
...($requireLongUrl ? [Validator\NotEmpty::NULL] : []),
])),
);
$this->add($deviceLongUrlsInput);
$validSince = $this->createInput(self::VALID_SINCE, false); $validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validSince); $this->add($validSince);
$validUntil = $this->createInput(self::VALID_UNTIL, false); $validUntil = $this->createInput(self::VALID_UNTIL, false);
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validUntil); $this->add($validUntil);
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value
// empty, is by using the deprecated setContinueIfEmpty // is by using the deprecated setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) {
true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v,
@ -102,10 +117,8 @@ class ShortUrlInputFilter extends InputFilter
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain); $this->add($domain);
$apiKeyInput = new Input(self::API_KEY); $apiKeyInput = $this->createInput(self::API_KEY, false);
$apiKeyInput $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
->setRequired(false)
->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
$this->add($apiKeyInput); $this->add($apiKeyInput);
$this->add($this->createTagsInput(self::TAGS, false)); $this->add($this->createTagsInput(self::TAGS, false));

View file

@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use function Shlinkio\Shlink\Core\enumValues;
class ShortUrlsParamsInputFilter extends InputFilter class ShortUrlsParamsInputFilter extends InputFilter
{ {
use Validation\InputFactoryTrait; use Validation\InputFactoryTrait;
@ -46,12 +48,12 @@ class ShortUrlsParamsInputFilter extends InputFilter
$tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode = $this->createInput(self::TAGS_MODE, false);
$tagsMode->getValidatorChain()->attach(new InArray([ $tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => TagsMode::values(), 'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT, 'strict' => InArray::COMPARE_STRICT,
])); ]));
$this->add($tagsMode); $this->add($tagsMode);
$this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values())); $this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false));
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false));

View file

@ -101,45 +101,45 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return $qb; return $qb;
} }
public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('s') $qb->select('s')
->from(ShortUrl::class, 's') ->from(ShortUrl::class, 's')
->where($qb->expr()->eq('s.longUrl', ':longUrl')) ->where($qb->expr()->eq('s.longUrl', ':longUrl'))
->setParameter('longUrl', $meta->getLongUrl()) ->setParameter('longUrl', $creation->longUrl)
->setMaxResults(1) ->setMaxResults(1)
->orderBy('s.id'); ->orderBy('s.id');
if ($meta->hasCustomSlug()) { if ($creation->hasCustomSlug()) {
$qb->andWhere($qb->expr()->eq('s.shortCode', ':slug')) $qb->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
->setParameter('slug', $meta->getCustomSlug()); ->setParameter('slug', $creation->customSlug);
} }
if ($meta->hasMaxVisits()) { if ($creation->hasMaxVisits()) {
$qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits')) $qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits'))
->setParameter('maxVisits', $meta->getMaxVisits()); ->setParameter('maxVisits', $creation->maxVisits);
} }
if ($meta->hasValidSince()) { if ($creation->hasValidSince()) {
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince')) $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME); ->setParameter('validSince', $creation->validSince, ChronosDateTimeType::CHRONOS_DATETIME);
} }
if ($meta->hasValidUntil()) { if ($creation->hasValidUntil()) {
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME); ->setParameter('validUntil', $creation->validUntil, ChronosDateTimeType::CHRONOS_DATETIME);
} }
if ($meta->hasDomain()) { if ($creation->hasDomain()) {
$qb->join('s.domain', 'd') $qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain')) ->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $meta->getDomain()); ->setParameter('domain', $creation->domain);
} }
$apiKey = $meta->getApiKey(); $apiKey = $creation->apiKey;
if ($apiKey !== null) { if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(), 's'); $this->applySpecification($qb, $apiKey->spec(), 's');
} }
$tags = $meta->getTags(); $tags = $creation->tags;
$tagsAmount = count($tags); $tagsAmount = count($tags);
if ($tagsAmount === 0) { if ($tagsAmount === 0) {
return $qb->getQuery()->getOneOrNullResult(); return $qb->getQuery()->getOneOrNullResult();

View file

@ -22,7 +22,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool;
public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl; public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl;
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl;
} }

View file

@ -49,7 +49,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
/** /**
* @param string[] $tags * @param string[] $tags
* @return Collection|Tag[] * @return Collection<int, Tag>
*/ */
public function resolveTags(array $tags): Collections\Collection public function resolveTags(array $tags): Collections\Collection
{ {

View file

@ -14,7 +14,7 @@ interface ShortUrlRelationResolverInterface
/** /**
* @param string[] $tags * @param string[] $tags
* @return Collection|Tag[] * @return Collection<int, Tag>
*/ */
public function resolveTags(array $tags): Collection; public function resolveTags(array $tags): Collection;
} }

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Doctrine\Common\Collections; use Doctrine\Common\Collections;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Entity\Tag;
@ -20,7 +19,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
/** /**
* @param string[] $tags * @param string[] $tags
* @return Collection|Tag[] * @return Collections\Collection<int, Tag>
*/ */
public function resolveTags(array $tags): Collections\Collection public function resolveTags(array $tags): Collections\Collection
{ {

View file

@ -34,7 +34,6 @@ class ShortUrlService implements ShortUrlServiceInterface
?ApiKey $apiKey = null, ?ApiKey $apiKey = null,
): ShortUrl { ): ShortUrl {
if ($shortUrlEdit->longUrlWasProvided()) { if ($shortUrlEdit->longUrlWasProvided()) {
/** @var ShortUrlEdition $shortUrlEdit */
$shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit);
} }

View file

@ -27,6 +27,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'shortCode' => $shortUrl->getShortCode(), 'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => $this->stringifier->stringify($shortUrl), 'shortUrl' => $this->stringifier->stringify($shortUrl),
'longUrl' => $shortUrl->getLongUrl(), 'longUrl' => $shortUrl->getLongUrl(),
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'tags' => invoke($shortUrl->getTags(), '__toString'), 'tags' => invoke($shortUrl->getTags(), '__toString'),
'meta' => $this->buildMeta($shortUrl), 'meta' => $this->buildMeta($shortUrl),

View file

@ -31,22 +31,21 @@ class UrlShortener implements UrlShortenerInterface
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function shorten(ShortUrlCreation $meta): ShortUrl public function shorten(ShortUrlCreation $creation): ShortUrl
{ {
// First, check if a short URL exists for all provided params // First, check if a short URL exists for all provided params
$existingShortUrl = $this->findExistingShortUrlIfExists($meta); $existingShortUrl = $this->findExistingShortUrlIfExists($creation);
if ($existingShortUrl !== null) { if ($existingShortUrl !== null) {
return $existingShortUrl; return $existingShortUrl;
} }
/** @var \Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation $meta */ $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
$meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta);
/** @var ShortUrl $newShortUrl */ /** @var ShortUrl $newShortUrl */
$newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { $newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl {
$shortUrl = ShortUrl::create($meta, $this->relationResolver); $shortUrl = ShortUrl::create($creation, $this->relationResolver);
$this->verifyShortCodeUniqueness($meta, $shortUrl); $this->verifyShortCodeUniqueness($creation, $shortUrl);
$this->em->persist($shortUrl); $this->em->persist($shortUrl);
return $shortUrl; return $shortUrl;
@ -57,15 +56,15 @@ class UrlShortener implements UrlShortenerInterface
return $newShortUrl; return $newShortUrl;
} }
private function findExistingShortUrlIfExists(ShortUrlCreation $meta): ?ShortUrl private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl
{ {
if (! $meta->findIfExists()) { if (! $creation->findIfExists) {
return null; return null;
} }
/** @var ShortUrlRepositoryInterface $repo */ /** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class); $repo = $this->em->getRepository(ShortUrl::class);
return $repo->findOneMatching($meta); return $repo->findOneMatching($creation);
} }
private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void
@ -77,7 +76,7 @@ class UrlShortener implements UrlShortenerInterface
if (! $couldBeMadeUnique) { if (! $couldBeMadeUnique) {
$domain = $shortUrlToBeCreated->getDomain(); $domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain?->getAuthority(); $domainAuthority = $domain?->authority;
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
} }

View file

@ -15,5 +15,5 @@ interface UrlShortenerInterface
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function shorten(ShortUrlCreation $meta): ShortUrl; public function shorten(ShortUrlCreation $creation): ShortUrl;
} }

View file

@ -26,7 +26,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
{ {
private const MAX_REDIRECTS = 15; private const MAX_REDIRECTS = 15;
private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/51.0.2704.103 Safari/537.36'; . 'Chrome/108.0.0.0 Safari/537.36';
public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options) public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options)
{ {

View file

@ -15,9 +15,9 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor;
class VisitsTracker implements VisitsTrackerInterface class VisitsTracker implements VisitsTrackerInterface
{ {
public function __construct( public function __construct(
private ORM\EntityManagerInterface $em, private readonly ORM\EntityManagerInterface $em,
private EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
private TrackingOptions $options, private readonly TrackingOptions $options,
) { ) {
} }
@ -62,6 +62,9 @@ class VisitsTracker implements VisitsTrackerInterface
$this->trackVisit($createVisit, $visitor); $this->trackVisit($createVisit, $visitor);
} }
/**
* @param callable(Visitor $visitor): Visit $createVisit
*/
private function trackVisit(callable $createVisit, Visitor $visitor): void private function trackVisit(callable $createVisit, Visitor $visitor): void
{ {
if ($this->options->disableTracking) { if ($this->options->disableTracking) {

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\Action;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
class RedirectTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideUserAgents
*/
public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void
{
$response = $this->callShortUrl('def456', $userAgent);
self::assertEquals($expectedRedirect, $response->getHeaderLine('Location'));
}
public function provideUserAgents(): iterable
{
yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android'];
yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios'];
yield 'desktop' => [
DESKTOP_USER_AGENT,
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
];
yield 'unknown' => [
null,
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
];
}
}

View file

@ -131,7 +131,7 @@ class DomainRepositoryTest extends DatabaseTestCase
{ {
return ShortUrl::create( return ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], ['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo'],
), ),
new class ($domain) implements ShortUrlRelationResolverInterface { new class ($domain) implements ShortUrlRelationResolverInterface {
public function __construct(private Domain $domain) public function __construct(private Domain $domain)

View file

@ -134,7 +134,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function findOneMatchingReturnsNullForNonExistingShortUrls(): void public function findOneMatchingReturnsNullForNonExistingShortUrls(): void
{ {
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::createEmpty()));
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'foobar']))); self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'foobar'])));
self::assertNull($this->repo->findOneMatching( self::assertNull($this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]), ShortUrlCreation::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]),
@ -270,7 +269,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'domain' => $rightDomain->getAuthority(), 'domain' => $rightDomain->authority,
'longUrl' => 'foo', 'longUrl' => 'foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]), $this->relationResolver); ]), $this->relationResolver);
@ -313,7 +312,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$shortUrl, $shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->getAuthority(), 'domain' => $rightDomain->authority,
'longUrl' => 'foo', 'longUrl' => 'foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
])), ])),
@ -322,7 +321,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$shortUrl, $shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->getAuthority(), 'domain' => $rightDomain->authority,
'apiKey' => $rightDomainApiKey, 'apiKey' => $rightDomainApiKey,
'longUrl' => 'foo', 'longUrl' => 'foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
@ -332,7 +331,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$shortUrl, $shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->getAuthority(), 'domain' => $rightDomain->authority,
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'longUrl' => 'foo', 'longUrl' => 'foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
@ -341,7 +340,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertNull( self::assertNull(
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->getAuthority(), 'domain' => $rightDomain->authority,
'apiKey' => $wrongDomainApiKey, 'apiKey' => $wrongDomainApiKey,
'longUrl' => 'foo', 'longUrl' => 'foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],

View file

@ -75,7 +75,7 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags] = array_chunk($names, 3); [$firstUrlTags] = array_chunk($names, 3);
$secondUrlTags = [$names[0]]; $secondUrlTags = [$names[0]];
$metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData(
['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey], ['longUrl' => 'longUrl', 'tags' => $tags, 'apiKey' => $apiKey],
); );
$shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); $shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver);
@ -242,14 +242,14 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3);
$shortUrl = ShortUrl::create( $shortUrl = ShortUrl::create(
ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]), ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => 'longUrl', 'tags' => $firstUrlTags]),
$this->relationResolver, $this->relationResolver,
); );
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$shortUrl2 = ShortUrl::create( $shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags], ['domain' => $domain->authority, 'longUrl' => 'longUrl', 'tags' => $secondUrlTags],
), ),
$this->relationResolver, $this->relationResolver,
); );

View file

@ -31,7 +31,7 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
*/ */
public function findVisitsReturnsProperVisits(int $blockSize): void public function findVisitsReturnsProperVisits(int $blockSize): void
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
for ($i = 0; $i < 6; $i++) { for ($i = 0; $i < 6; $i++) {

View file

@ -264,7 +264,9 @@ class VisitRepositoryTest extends DatabaseTestCase
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($apiKey1); $this->getEntityManager()->persist($apiKey1);
$shortUrl = ShortUrl::create( $shortUrl = ShortUrl::create(
ShortUrlCreation::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), ShortUrlCreation::fromRawData(
['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'longUrl'],
),
$this->relationResolver, $this->relationResolver,
); );
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
@ -272,12 +274,14 @@ class VisitRepositoryTest extends DatabaseTestCase
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($apiKey2); $this->getEntityManager()->persist($apiKey2);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'longUrl']));
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 5); $this->createVisitsForShortUrl($shortUrl2, 5);
$shortUrl3 = ShortUrl::create( $shortUrl3 = ShortUrl::create(
ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), ShortUrlCreation::fromRawData(
['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'longUrl'],
),
$this->relationResolver, $this->relationResolver,
); );
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
@ -315,7 +319,7 @@ class VisitRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function findOrphanVisitsReturnsExpectedResult(): void public function findOrphanVisitsReturnsExpectedResult(): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl']));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7); $this->createVisitsForShortUrl($shortUrl, 7);
@ -364,7 +368,7 @@ class VisitRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function countOrphanVisitsReturnsExpectedResult(): void public function countOrphanVisitsReturnsExpectedResult(): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl']));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7); $this->createVisitsForShortUrl($shortUrl, 7);
@ -460,7 +464,7 @@ class VisitRepositoryTest extends DatabaseTestCase
} }
/** /**
* @return array{string, string, \Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl} * @return array{string, string, ShortUrl}
*/ */
private function createShortUrlsAndVisits( private function createShortUrlsAndVisits(
bool|string $withDomain = true, bool|string $withDomain = true,
@ -468,7 +472,7 @@ class VisitRepositoryTest extends DatabaseTestCase
?ApiKey $apiKey = null, ?ApiKey $apiKey = null,
): array { ): array {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => '', ShortUrlInputFilter::LONG_URL => 'longUrl',
ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::API_KEY => $apiKey, ShortUrlInputFilter::API_KEY => $apiKey,
]), $this->relationResolver); ]), $this->relationResolver);
@ -482,7 +486,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => $shortCode, 'customSlug' => $shortCode,
'domain' => $domain, 'domain' => $domain,
'longUrl' => '', 'longUrl' => 'longUrl',
])); ]));
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);
$this->createVisitsForShortUrl($shortUrlWithDomain, 3); $this->createVisitsForShortUrl($shortUrlWithDomain, 3);

View file

@ -56,7 +56,7 @@ class QrCodeActionTest extends TestCase
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''),
)->willReturn(ShortUrl::createEmpty()); )->willReturn(ShortUrl::createFake());
$delegate = $this->createMock(RequestHandlerInterface::class); $delegate = $this->createMock(RequestHandlerInterface::class);
$delegate->expects($this->never())->method('handle'); $delegate->expects($this->never())->method('handle');
@ -78,7 +78,7 @@ class QrCodeActionTest extends TestCase
$code = 'abc123'; $code = 'abc123';
$this->urlResolver->method('resolveEnabledShortUrl')->with( $this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), ShortUrlIdentifier::fromShortCodeAndDomain($code, ''),
)->willReturn(ShortUrl::createEmpty()); )->willReturn(ShortUrl::createFake());
$delegate = $this->createMock(RequestHandlerInterface::class); $delegate = $this->createMock(RequestHandlerInterface::class);
$req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query);
@ -111,7 +111,7 @@ class QrCodeActionTest extends TestCase
$code = 'abc123'; $code = 'abc123';
$this->urlResolver->method('resolveEnabledShortUrl')->with( $this->urlResolver->method('resolveEnabledShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), ShortUrlIdentifier::fromShortCodeAndDomain($code, ''),
)->willReturn(ShortUrl::createEmpty()); )->willReturn(ShortUrl::createFake());
$delegate = $this->createMock(RequestHandlerInterface::class); $delegate = $this->createMock(RequestHandlerInterface::class);
$resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate);

View file

@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\Config;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Config\EnvVars;
use function Functional\map;
use function putenv; use function putenv;
class EnvVarsTest extends TestCase class EnvVarsTest extends TestCase
@ -59,11 +58,4 @@ class EnvVarsTest extends TestCase
yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null];
yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar'];
} }
/** @test */
public function allValuesCanBeListed(): void
{
$expected = map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value);
self::assertEquals(EnvVars::values(), $expected);
}
} }

View file

@ -70,7 +70,7 @@ class LocateVisitTest extends TestCase
{ {
$event = new UrlVisited('123'); $event = new UrlVisited('123');
$this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')),
); );
$this->em->expects($this->never())->method('flush'); $this->em->expects($this->never())->method('flush');
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false);
@ -89,7 +89,7 @@ class LocateVisitTest extends TestCase
{ {
$event = new UrlVisited('123'); $event = new UrlVisited('123');
$this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')),
); );
$this->em->expects($this->never())->method('flush'); $this->em->expects($this->never())->method('flush');
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true);
@ -110,7 +110,7 @@ class LocateVisitTest extends TestCase
{ {
$event = new UrlVisited('123'); $event = new UrlVisited('123');
$this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')),
); );
$this->em->expects($this->never())->method('flush'); $this->em->expects($this->never())->method('flush');
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true);
@ -148,7 +148,7 @@ class LocateVisitTest extends TestCase
public function provideNonLocatableVisits(): iterable public function provideNonLocatableVisits(): iterable
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createFake();
yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))];
yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))];
@ -183,11 +183,11 @@ class LocateVisitTest extends TestCase
public function provideIpAddresses(): iterable public function provideIpAddresses(): iterable
{ {
yield 'no original IP address' => [ yield 'no original IP address' => [
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')),
null, null,
]; ];
yield 'original IP address' => [ yield 'original IP address' => [
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')),
'1.2.3.4', '1.2.3.4',
]; ];
yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];

View file

@ -57,7 +57,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase
/** @test */ /** @test */
public function expectedNotificationIsPublished(): void public function expectedNotificationIsPublished(): void
{ {
$shortUrl = ShortUrl::withLongUrl(''); $shortUrl = ShortUrl::withLongUrl('longUrl');
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl);
@ -74,7 +74,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase
/** @test */ /** @test */
public function messageIsPrintedIfPublishingFails(): void public function messageIsPrintedIfPublishingFails(): void
{ {
$shortUrl = ShortUrl::withLongUrl(''); $shortUrl = ShortUrl::withLongUrl('longUrl');
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$e = new Exception('Error'); $e = new Exception('Error');

View file

@ -59,7 +59,7 @@ class NotifyVisitToMercureTest extends TestCase
public function notificationsAreSentWhenVisitIsFound(): void public function notificationsAreSentWhenVisitIsFound(): void
{ {
$visitId = '123'; $visitId = '123';
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit);
@ -79,7 +79,7 @@ class NotifyVisitToMercureTest extends TestCase
public function debugIsLoggedWhenExceptionIsThrown(): void public function debugIsLoggedWhenExceptionIsThrown(): void
{ {
$visitId = '123'; $visitId = '123';
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$e = new RuntimeException('Error'); $e = new RuntimeException('Error');

View file

@ -123,7 +123,7 @@ class NotifyVisitToWebHooksTest extends TestCase
public function provideVisits(): iterable public function provideVisits(): iterable
{ {
yield 'regular visit' => [ yield 'regular visit' => [
Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()), Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()),
['shortUrl', 'visit'], ['shortUrl', 'visit'],
]; ];
yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],]; yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],];

View file

@ -38,7 +38,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'foo', 'customSlug' => 'foo',
'longUrl' => '', 'longUrl' => 'longUrl',
'title' => $title, 'title' => $title,
])); ]));
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
@ -51,7 +51,8 @@ class PublishingUpdatesGeneratorTest extends TestCase
'shortUrl' => [ 'shortUrl' => [
'shortCode' => $shortUrl->getShortCode(), 'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(),
'longUrl' => '', 'longUrl' => 'longUrl',
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'visitsCount' => 0, 'visitsCount' => 0,
'tags' => [], 'tags' => [],
@ -118,7 +119,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'foo', 'customSlug' => 'foo',
'longUrl' => '', 'longUrl' => 'longUrl',
'title' => 'The title', 'title' => 'The title',
])); ]));
@ -128,7 +129,8 @@ class PublishingUpdatesGeneratorTest extends TestCase
self::assertEquals(['shortUrl' => [ self::assertEquals(['shortUrl' => [
'shortCode' => $shortUrl->getShortCode(), 'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(),
'longUrl' => '', 'longUrl' => 'longUrl',
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'visitsCount' => 0, 'visitsCount' => 0,
'tags' => [], 'tags' => [],

View file

@ -68,7 +68,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase
$shortUrlId = '123'; $shortUrlId = '123';
$update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []);
$this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn(
ShortUrl::withLongUrl(''), ShortUrl::withLongUrl('longUrl'),
); );
$this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with(
$this->isInstanceOf(ShortUrl::class), $this->isInstanceOf(ShortUrl::class),
@ -88,7 +88,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase
$shortUrlId = '123'; $shortUrlId = '123';
$update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []);
$this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn(
ShortUrl::withLongUrl(''), ShortUrl::withLongUrl('longUrl'),
); );
$this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with(
$this->isInstanceOf(ShortUrl::class), $this->isInstanceOf(ShortUrl::class),

View file

@ -159,7 +159,7 @@ class NotifyVisitToRabbitMqTest extends TestCase
{ {
yield 'legacy non-orphan visit' => [ yield 'legacy non-orphan visit' => [
true, true,
$visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()),
noop(...), noop(...),
function (MockObject & PublishingHelperInterface $helper) use ($visit): void { function (MockObject & PublishingHelperInterface $helper) use ($visit): void {
$helper->method('publishUpdate')->with($this->callback(function (Update $update) use ($visit): bool { $helper->method('publishUpdate')->with($this->callback(function (Update $update) use ($visit): bool {
@ -190,7 +190,7 @@ class NotifyVisitToRabbitMqTest extends TestCase
]; ];
yield 'non-legacy non-orphan visit' => [ yield 'non-legacy non-orphan visit' => [
false, false,
Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()),
function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void {
$update = Update::forTopicAndPayload('', []); $update = Update::forTopicAndPayload('', []);
$updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); $updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate');

View file

@ -55,7 +55,7 @@ class NotifyNewShortUrlToRedisTest extends TestCase
$shortUrlId = '123'; $shortUrlId = '123';
$update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []);
$this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn(
ShortUrl::withLongUrl(''), ShortUrl::withLongUrl('longUrl'),
); );
$this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with(
$this->isInstanceOf(ShortUrl::class), $this->isInstanceOf(ShortUrl::class),

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Functions;
use BackedEnum;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use function Functional\map;
use function Shlinkio\Shlink\Core\enumValues;
class FunctionsTest extends TestCase
{
/**
* @param class-string<BackedEnum> $enum
* @test
* @dataProvider provideEnums
*/
public function enumValuesReturnsExpectedValueForEnum(string $enum, array $expectedValues): void
{
self::assertEquals($expectedValues, enumValues($enum));
}
public function provideEnums(): iterable
{
yield EnvVars::class => [EnvVars::class, map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value)];
yield VisitType::class => [
VisitType::class,
map(VisitType::cases(), static fn (VisitType $envVar) => $envVar->value),
];
yield DeviceType::class => [
DeviceType::class,
map(DeviceType::cases(), static fn (DeviceType $envVar) => $envVar->value),
];
yield OrderableField::class => [
OrderableField::class,
map(OrderableField::cases(), static fn (OrderableField $envVar) => $envVar->value),
];
}
}

View file

@ -210,7 +210,7 @@ class ImportedLinksProcessorTest extends TestCase
]), ]),
'<comment>Skipped</comment>. Imported <info>4</info> visits', '<comment>Skipped</comment>. Imported <info>4</info> visits',
4, 4,
ShortUrl::createEmpty(), ShortUrl::createFake(),
]; ];
yield 'existing short URL with previous imported visits' => [ yield 'existing short URL with previous imported visits' => [
$createImportedUrl([ $createImportedUrl([
@ -222,8 +222,8 @@ class ImportedLinksProcessorTest extends TestCase
]), ]),
'<comment>Skipped</comment>. Imported <info>2</info> visits', '<comment>Skipped</comment>. Imported <info>2</info> visits',
2, 2,
ShortUrl::createEmpty()->setVisits(new ArrayCollection([ ShortUrl::createFake()->setVisits(new ArrayCollection([
Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)), Visit::fromImport(ShortUrl::createFake(), new ImportedShlinkVisit('', '', $now, null)),
])), ])),
]; ];
} }

View file

@ -29,8 +29,8 @@ class DeleteShortUrlServiceTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection(
map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())), map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())),
)); ));
$this->shortCode = $shortUrl->getShortCode(); $this->shortCode = $shortUrl->getShortCode();

View file

@ -7,8 +7,10 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSource; use Shlinkio\Shlink\Importer\Sources\ImportSource;
@ -38,11 +40,11 @@ class ShortUrlTest extends TestCase
public function provideInvalidShortUrls(): iterable public function provideInvalidShortUrls(): iterable
{ {
yield 'with custom slug' => [ yield 'with custom slug' => [
ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => 'longUrl'])),
'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.',
]; ];
yield 'already persisted' => [ yield 'already persisted' => [
ShortUrl::createEmpty()->setId('1'), ShortUrl::createFake()->setId('1'),
'The short code can be regenerated only on new ShortUrls which have not been persisted yet.', 'The short code can be regenerated only on new ShortUrls which have not been persisted yet.',
]; ];
} }
@ -64,9 +66,9 @@ class ShortUrlTest extends TestCase
public function provideValidShortUrls(): iterable public function provideValidShortUrls(): iterable
{ {
yield 'no custom slug' => [ShortUrl::createEmpty()]; yield 'no custom slug' => [ShortUrl::createFake()];
yield 'imported with custom slug' => [ShortUrl::fromImport( yield 'imported with custom slug' => [ShortUrl::fromImport(
new ImportedShlinkUrl(ImportSource::BITLY, '', [], Chronos::now(), null, 'custom-slug', null), new ImportedShlinkUrl(ImportSource::BITLY, 'longUrl', [], Chronos::now(), null, 'custom-slug', null),
true, true,
)]; )];
} }
@ -78,7 +80,7 @@ class ShortUrlTest extends TestCase
public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
[ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'longUrl'],
)); ));
self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); self::assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
@ -89,4 +91,46 @@ class ShortUrlTest extends TestCase
yield [null, DEFAULT_SHORT_CODES_LENGTH]; yield [null, DEFAULT_SHORT_CODES_LENGTH];
yield from map(range(4, 10), fn (int $value) => [$value, $value]); yield from map(range(4, 10), fn (int $value) => [$value, $value]);
} }
/** @test */
public function deviceLongUrlsAreUpdated(): void
{
$shortUrl = ShortUrl::withLongUrl('foo');
$shortUrl->update(ShortUrlEdition::fromRawData([
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::ANDROID->value => 'android',
DeviceType::IOS->value => 'ios',
],
]));
self::assertEquals([
DeviceType::ANDROID->value => 'android',
DeviceType::IOS->value => 'ios',
DeviceType::DESKTOP->value => null,
], $shortUrl->deviceLongUrls());
$shortUrl->update(ShortUrlEdition::fromRawData([
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::ANDROID->value => null,
DeviceType::DESKTOP->value => 'desktop',
],
]));
self::assertEquals([
DeviceType::ANDROID->value => null,
DeviceType::IOS->value => 'ios',
DeviceType::DESKTOP->value => 'desktop',
], $shortUrl->deviceLongUrls());
$shortUrl->update(ShortUrlEdition::fromRawData([
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::ANDROID->value => null,
DeviceType::IOS->value => null,
],
]));
self::assertEquals([
DeviceType::ANDROID->value => null,
DeviceType::IOS->value => null,
DeviceType::DESKTOP->value => 'desktop',
], $shortUrl->deviceLongUrls());
}
} }

View file

@ -4,12 +4,19 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
class ShortUrlRedirectionBuilderTest extends TestCase class ShortUrlRedirectionBuilderTest extends TestCase
{ {
private ShortUrlRedirectionBuilder $redirectionBuilder; private ShortUrlRedirectionBuilder $redirectionBuilder;
@ -26,74 +33,92 @@ class ShortUrlRedirectionBuilderTest extends TestCase
*/ */
public function buildShortUrlRedirectBuildsExpectedUrl( public function buildShortUrlRedirectBuildsExpectedUrl(
string $expectedUrl, string $expectedUrl,
array $query, ServerRequestInterface $request,
?string $extraPath, ?string $extraPath,
?bool $forwardQuery, ?bool $forwardQuery,
): void { ): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://domain.com/foo/bar?some=thing', 'longUrl' => 'https://domain.com/foo/bar?some=thing',
'forwardQuery' => $forwardQuery, 'forwardQuery' => $forwardQuery,
'deviceLongUrls' => [
DeviceType::ANDROID->value => 'https://domain.com/android',
DeviceType::IOS->value => 'https://domain.com/ios',
],
])); ]));
$result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
self::assertEquals($expectedUrl, $result); self::assertEquals($expectedUrl, $result);
} }
public function provideData(): iterable public function provideData(): iterable
{ {
yield ['https://domain.com/foo/bar?some=thing', [], null, true]; $request = static fn (array $query = []) => ServerRequestFactory::fromGlobals()->withQueryParams($query);
yield ['https://domain.com/foo/bar?some=thing', [], null, null];
yield ['https://domain.com/foo/bar?some=thing', [], null, false]; yield ['https://domain.com/foo/bar?some=thing', $request(), null, true];
yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null, true]; yield ['https://domain.com/foo/bar?some=thing', $request(), null, null];
yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, true]; yield ['https://domain.com/foo/bar?some=thing', $request(), null, false];
yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, null]; yield ['https://domain.com/foo/bar?some=thing&else', $request(['else' => null]), null, true];
yield ['https://domain.com/foo/bar?some=thing', ['foo' => 'bar'], null, false]; yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, true];
yield ['https://domain.com/foo/bar?some=thing&123=foo', ['123' => 'foo'], null, true]; yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, null];
yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, true]; yield ['https://domain.com/foo/bar?some=thing', $request(['foo' => 'bar']), null, false];
yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, null]; yield ['https://domain.com/foo/bar?some=thing&123=foo', $request(['123' => 'foo']), null, true];
yield ['https://domain.com/foo/bar?some=thing', [456 => 'foo'], null, false]; yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, true];
yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, null];
yield ['https://domain.com/foo/bar?some=thing', $request([456 => 'foo']), null, false];
yield [ yield [
'https://domain.com/foo/bar?some=overwritten&foo=bar', 'https://domain.com/foo/bar?some=overwritten&foo=bar',
['foo' => 'bar', 'some' => 'overwritten'], $request(['foo' => 'bar', 'some' => 'overwritten']),
null, null,
true, true,
]; ];
yield [ yield [
'https://domain.com/foo/bar?some=overwritten', 'https://domain.com/foo/bar?some=overwritten',
['foobar' => 'notrack', 'some' => 'overwritten'], $request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'),
null, null,
true, true,
]; ];
yield [ yield [
'https://domain.com/foo/bar?some=overwritten', 'https://domain.com/foo/bar?some=overwritten',
['foobar' => 'notrack', 'some' => 'overwritten'], $request(['foobar' => 'notrack', 'some' => 'overwritten']),
null, null,
null, null,
]; ];
yield [ yield [
'https://domain.com/foo/bar?some=thing', 'https://domain.com/foo/bar?some=thing',
['foobar' => 'notrack', 'some' => 'overwritten'], $request(['foobar' => 'notrack', 'some' => 'overwritten']),
null, null,
false, false,
]; ];
yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz', true]; yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true];
yield [ yield [
'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world',
['hello' => 'world'], $request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT),
'/something/else-baz', '/something/else-baz',
true, true,
]; ];
yield [ yield [
'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world',
['hello' => 'world'], $request(['hello' => 'world']),
'/something/else-baz', '/something/else-baz',
null, null,
]; ];
yield [ yield [
'https://domain.com/foo/bar/something/else-baz?some=thing', 'https://domain.com/foo/bar/something/else-baz?some=thing',
['hello' => 'world'], $request(['hello' => 'world']),
'/something/else-baz', '/something/else-baz',
false, false,
]; ];
yield [
'https://domain.com/android/something',
$request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT),
'/something',
false,
];
yield [
'https://domain.com/ios?foo=bar',
$request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT),
null,
null,
];
} }
} }

View file

@ -30,7 +30,7 @@ class ShortUrlStringifierTest extends TestCase
{ {
$shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create(
ShortUrlCreation::fromRawData([ ShortUrlCreation::fromRawData([
'longUrl' => '', 'longUrl' => 'longUrl',
'customSlug' => $shortCode, 'customSlug' => $shortCode,
'domain' => $domain, 'domain' => $domain,
]), ]),

View file

@ -142,7 +142,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
$type->method('isInvalidShortUrl')->willReturn(true); $type->method('isInvalidShortUrl')->willReturn(true);
$request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type)
->withUri(new Uri('https://s.test/shortCode/bar/baz')); ->withUri(new Uri('https://s.test/shortCode/bar/baz'));
$shortUrl = ShortUrl::withLongUrl(''); $shortUrl = ShortUrl::withLongUrl('longUrl');
$currentIteration = 1; $currentIteration = 1;
$this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with( $this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with(
@ -159,7 +159,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase
); );
$this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with(
$shortUrl, $shortUrl,
[], $this->isInstanceOf(ServerRequestInterface::class),
$expectedExtraPath, $expectedExtraPath,
)->willReturn('the_built_long_url'); )->willReturn('the_built_long_url');
$this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with(

View file

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use stdClass; use stdClass;
@ -69,6 +70,40 @@ class ShortUrlCreationTest extends TestCase
yield [[ yield [[
ShortUrlInputFilter::LONG_URL => [], ShortUrlInputFilter::LONG_URL => [],
]]; ]];
yield [[
ShortUrlInputFilter::LONG_URL => null,
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::DEVICE_LONG_URLS => [
'invalid' => 'https://shlink.io',
],
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::DESKTOP->value => '',
],
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::DESKTOP->value => null,
],
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::IOS->value => ' ',
],
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::DEVICE_LONG_URLS => [
DeviceType::IOS->value => 'bar',
DeviceType::ANDROID->value => [],
],
]];
} }
/** /**
@ -80,24 +115,24 @@ class ShortUrlCreationTest extends TestCase
string $expectedSlug, string $expectedSlug,
bool $multiSegmentEnabled = false, bool $multiSegmentEnabled = false,
): void { ): void {
$meta = ShortUrlCreation::fromRawData([ $creation = ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'validSince' => Chronos::parse('2015-01-01')->toAtomString(),
'customSlug' => $customSlug, 'customSlug' => $customSlug,
'longUrl' => '', 'longUrl' => 'longUrl',
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled,
]); ]);
self::assertTrue($meta->hasValidSince()); self::assertTrue($creation->hasValidSince());
self::assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince);
self::assertFalse($meta->hasValidUntil()); self::assertFalse($creation->hasValidUntil());
self::assertNull($meta->getValidUntil()); self::assertNull($creation->validUntil);
self::assertTrue($meta->hasCustomSlug()); self::assertTrue($creation->hasCustomSlug());
self::assertEquals($expectedSlug, $meta->getCustomSlug()); self::assertEquals($expectedSlug, $creation->customSlug);
self::assertFalse($meta->hasMaxVisits()); self::assertFalse($creation->hasMaxVisits());
self::assertNull($meta->getMaxVisits()); self::assertNull($creation->maxVisits);
} }
public function provideCustomSlugs(): iterable public function provideCustomSlugs(): iterable
@ -127,12 +162,12 @@ class ShortUrlCreationTest extends TestCase
*/ */
public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void
{ {
$meta = ShortUrlCreation::fromRawData([ $creation = ShortUrlCreation::fromRawData([
'title' => $title, 'title' => $title,
'longUrl' => '', 'longUrl' => 'longUrl',
]); ]);
self::assertEquals($expectedTitle, $meta->getTitle()); self::assertEquals($expectedTitle, $creation->title);
} }
public function provideTitles(): iterable public function provideTitles(): iterable
@ -153,12 +188,12 @@ class ShortUrlCreationTest extends TestCase
*/ */
public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void
{ {
$meta = ShortUrlCreation::fromRawData([ $creation = ShortUrlCreation::fromRawData([
'domain' => $domain, 'domain' => $domain,
'longUrl' => '', 'longUrl' => 'longUrl',
]); ]);
self::assertSame($expectedDomain, $meta->getDomain()); self::assertSame($expectedDomain, $creation->domain);
} }
public function provideDomains(): iterable public function provideDomains(): iterable

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Model;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
class ShortUrlEditionTest extends TestCase
{
/**
* @test
* @dataProvider provideDeviceLongUrls
*/
public function expectedDeviceLongUrlsAreResolved(
?array $deviceLongUrls,
array $expectedDeviceLongUrls,
array $expectedDevicesToRemove,
): void {
$edition = ShortUrlEdition::fromRawData([ShortUrlInputFilter::DEVICE_LONG_URLS => $deviceLongUrls]);
self::assertEquals($expectedDeviceLongUrls, $edition->deviceLongUrls);
self::assertEquals($expectedDevicesToRemove, $edition->devicesToRemove);
}
public function provideDeviceLongUrls(): iterable
{
yield 'null' => [null, [], []];
yield 'empty' => [[], [], []];
yield 'only new urls' => [[
DeviceType::DESKTOP->value => 'foo',
DeviceType::IOS->value => 'bar',
], [
DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'foo'),
DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'bar'),
], []];
yield 'only urls to remove' => [[
DeviceType::ANDROID->value => null,
DeviceType::IOS->value => null,
], [], [DeviceType::ANDROID, DeviceType::IOS]];
yield 'both' => [[
DeviceType::DESKTOP->value => 'bar',
DeviceType::IOS->value => 'foo',
DeviceType::ANDROID->value => null,
], [
DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'bar'),
DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'foo'),
], [DeviceType::ANDROID]];
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\Validator\NotEmpty;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\DeviceLongUrlsValidator;
use stdClass;
class DeviceLongUrlsValidatorTest extends TestCase
{
private DeviceLongUrlsValidator $validator;
protected function setUp(): void
{
$this->validator = new DeviceLongUrlsValidator(new NotEmpty());
}
/**
* @test
* @dataProvider provideNonArrayValues
*/
public function nonArrayValuesAreNotValid(mixed $invalidValue): void
{
self::assertFalse($this->validator->isValid($invalidValue));
self::assertEquals(['NOT_ARRAY' => 'Provided value is not an array.'], $this->validator->getMessages());
}
public function provideNonArrayValues(): iterable
{
yield 'int' => [0];
yield 'float' => [100.45];
yield 'string' => ['foo'];
yield 'boolean' => [true];
yield 'object' => [new stdClass()];
yield 'null' => [null];
}
/** @test */
public function unrecognizedKeysAreNotValid(): void
{
self::assertFalse($this->validator->isValid(['foo' => 'bar']));
self::assertEquals(
['INVALID_DEVICE' => 'You have provided at least one invalid device identifier.'],
$this->validator->getMessages(),
);
}
/** @test */
public function everyUrlMustMatchLongUrlValidator(): void
{
self::assertFalse($this->validator->isValid([DeviceType::ANDROID->value => '']));
self::assertEquals(
['INVALID_LONG_URL' => 'At least one of the long URLs are invalid.'],
$this->validator->getMessages(),
);
}
/** @test */
public function validValuesResultInValidResult(): void
{
self::assertTrue($this->validator->isValid([
DeviceType::ANDROID->value => 'foo',
DeviceType::IOS->value => 'bar',
DeviceType::DESKTOP->value => 'baz',
]));
}
}

View file

@ -52,7 +52,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
self::assertSame($result, $foundDomain); self::assertSame($result, $foundDomain);
} }
self::assertInstanceOf(Domain::class, $result); self::assertInstanceOf(Domain::class, $result);
self::assertEquals($authority, $result->getAuthority()); self::assertEquals($authority, $result->authority);
} }
public function provideFoundDomains(): iterable public function provideFoundDomains(): iterable

View file

@ -30,7 +30,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase
self::assertNull($result); self::assertNull($result);
} else { } else {
self::assertInstanceOf(Domain::class, $result); self::assertInstanceOf(Domain::class, $result);
self::assertEquals($domain, $result->getAuthority()); self::assertEquals($domain, $result->authority);
} }
} }

View file

@ -36,10 +36,10 @@ class ShortUrlListServiceTest extends TestCase
public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void
{ {
$list = [ $list = [
ShortUrl::createEmpty(), ShortUrl::createFake(),
ShortUrl::createEmpty(), ShortUrl::createFake(),
ShortUrl::createEmpty(), ShortUrl::createFake(),
ShortUrl::createEmpty(), ShortUrl::createFake(),
]; ];
$this->repo->expects($this->once())->method('findList')->willReturn($list); $this->repo->expects($this->once())->method('findList')->willReturn($list);

View file

@ -114,7 +114,7 @@ class ShortUrlResolverTest extends TestCase
$now = Chronos::now(); $now = Chronos::now();
yield 'maxVisits reached' => [(function () { yield 'maxVisits reached' => [(function () {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'longUrl']));
$shortUrl->setVisits(new ArrayCollection(map( $shortUrl->setVisits(new ArrayCollection(map(
range(0, 4), range(0, 4),
fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()),
@ -123,16 +123,16 @@ class ShortUrlResolverTest extends TestCase
return $shortUrl; return $shortUrl;
})()]; })()];
yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => ''], ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => 'longUrl'],
))]; ))];
yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData(
['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => ''], ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => 'longUrl'],
))]; ))];
yield 'mixed' => [(function () use ($now) { yield 'mixed' => [(function () use ($now) {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'maxVisits' => 3, 'maxVisits' => 3,
'validUntil' => $now->subMonth()->toAtomString(), 'validUntil' => $now->subMonth()->toAtomString(),
'longUrl' => '', 'longUrl' => 'longUrl',
])); ]));
$shortUrl->setVisits(new ArrayCollection(map( $shortUrl->setVisits(new ArrayCollection(map(
range(0, 4), range(0, 4),

View file

@ -7,7 +7,9 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
@ -18,26 +20,28 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function array_fill_keys;
use function Shlinkio\Shlink\Core\enumValues;
class ShortUrlServiceTest extends TestCase class ShortUrlServiceTest extends TestCase
{ {
use ApiKeyHelpersTrait; use ApiKeyHelpersTrait;
private ShortUrlService $service; private ShortUrlService $service;
private MockObject & EntityManagerInterface $em;
private MockObject & ShortUrlResolverInterface $urlResolver; private MockObject & ShortUrlResolverInterface $urlResolver;
private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
protected function setUp(): void protected function setUp(): void
{ {
$this->em = $this->createMock(EntityManagerInterface::class); $em = $this->createMock(EntityManagerInterface::class);
$this->em->method('persist')->willReturn(null); $em->method('persist')->willReturn(null);
$this->em->method('flush')->willReturn(null); $em->method('flush')->willReturn(null);
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
$this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class);
$this->service = new ShortUrlService( $this->service = new ShortUrlService(
$this->em, $em,
$this->urlResolver, $this->urlResolver,
$this->titleResolutionHelper, $this->titleResolutionHelper,
new SimpleShortUrlRelationResolver(), new SimpleShortUrlRelationResolver(),
@ -49,7 +53,7 @@ class ShortUrlServiceTest extends TestCase
* @dataProvider provideShortUrlEdits * @dataProvider provideShortUrlEdits
*/ */
public function updateShortUrlUpdatesProvidedData( public function updateShortUrlUpdatesProvidedData(
int $expectedValidateCalls, InvocationOrder $expectedValidateCalls,
ShortUrlEdition $shortUrlEdit, ShortUrlEdition $shortUrlEdit,
?ApiKey $apiKey, ?ApiKey $apiKey,
): void { ): void {
@ -61,7 +65,7 @@ class ShortUrlServiceTest extends TestCase
$apiKey, $apiKey,
)->willReturn($shortUrl); )->willReturn($shortUrl);
$this->titleResolutionHelper->expects($this->exactly($expectedValidateCalls)) $this->titleResolutionHelper->expects($expectedValidateCalls)
->method('processTitleAndValidateUrl') ->method('processTitleAndValidateUrl')
->with($shortUrlEdit) ->with($shortUrlEdit)
->willReturn($shortUrlEdit); ->willReturn($shortUrlEdit);
@ -72,34 +76,44 @@ class ShortUrlServiceTest extends TestCase
$apiKey, $apiKey,
); );
$resolveDeviceLongUrls = function () use ($shortUrlEdit): array {
$result = array_fill_keys(enumValues(DeviceType::class), null);
foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) {
$result[$longUrl->deviceType->value] = $longUrl->longUrl;
}
return $result;
};
self::assertSame($shortUrl, $result); self::assertSame($shortUrl, $result);
self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince());
self::assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil());
self::assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits());
self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl());
self::assertEquals($resolveDeviceLongUrls(), $shortUrl->deviceLongUrls());
} }
public function provideShortUrlEdits(): iterable public function provideShortUrlEdits(): iterable
{ {
yield 'no long URL' => [0, ShortUrlEdition::fromRawData( yield 'no long URL' => [$this->never(), ShortUrlEdition::fromRawData([
[ 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), 'maxVisits' => 5,
'maxVisits' => 5, ]), null];
yield 'long URL and API key' => [$this->once(), ShortUrlEdition::fromRawData([
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'maxVisits' => 10,
'longUrl' => 'modifiedLongUrl',
]), ApiKey::create()];
yield 'long URL with validation' => [$this->once(), ShortUrlEdition::fromRawData([
'longUrl' => 'modifiedLongUrl',
'validateUrl' => true,
]), null];
yield 'device redirects' => [$this->never(), ShortUrlEdition::fromRawData([
'deviceLongUrls' => [
DeviceType::IOS->value => 'iosLongUrl',
DeviceType::ANDROID->value => 'androidLongUrl',
], ],
), null]; ]), null];
yield 'long URL' => [1, ShortUrlEdition::fromRawData(
[
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'maxVisits' => 10,
'longUrl' => 'modifiedLongUrl',
],
), ApiKey::create()];
yield 'long URL with validation' => [1, ShortUrlEdition::fromRawData(
[
'longUrl' => 'modifiedLongUrl',
'validateUrl' => true,
],
), null];
} }
} }

View file

@ -38,14 +38,14 @@ class ShortUrlDataTransformerTest extends TestCase
$maxVisits = random_int(1, 1000); $maxVisits = random_int(1, 1000);
$now = Chronos::now(); $now = Chronos::now();
yield 'no metadata' => [ShortUrl::createEmpty(), [ yield 'no metadata' => [ShortUrl::createFake(), [
'validSince' => null, 'validSince' => null,
'validUntil' => null, 'validUntil' => null,
'maxVisits' => null, 'maxVisits' => null,
]]; ]];
yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([ yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([
'maxVisits' => $maxVisits, 'maxVisits' => $maxVisits,
'longUrl' => '', 'longUrl' => 'longUrl',
])), [ ])), [
'validSince' => null, 'validSince' => null,
'validUntil' => null, 'validUntil' => null,
@ -53,7 +53,7 @@ class ShortUrlDataTransformerTest extends TestCase
]]; ]];
yield 'max visits and valid since' => [ yield 'max visits and valid since' => [
ShortUrl::create(ShortUrlCreation::fromRawData( ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => ''], ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => 'longUrl'],
)), )),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
@ -63,7 +63,7 @@ class ShortUrlDataTransformerTest extends TestCase
]; ];
yield 'both dates' => [ yield 'both dates' => [
ShortUrl::create(ShortUrlCreation::fromRawData( ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => ''], ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => 'longUrl'],
)), )),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
@ -72,9 +72,12 @@ class ShortUrlDataTransformerTest extends TestCase
], ],
]; ];
yield 'everything' => [ yield 'everything' => [
ShortUrl::create(ShortUrlCreation::fromRawData( ShortUrl::create(ShortUrlCreation::fromRawData([
['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits, 'longUrl' => ''], 'validSince' => $now,
)), 'validUntil' => $now->subDays(5),
'maxVisits' => $maxVisits,
'longUrl' => 'longUrl',
])),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
'validUntil' => $now->subDays(5)->toAtomString(), 'validUntil' => $now->subDays(5)->toAtomString(),

View file

@ -18,7 +18,7 @@ class VisitTest extends TestCase
*/ */
public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor($userAgent, 'some site', '1.2.3.4', ''));
self::assertEquals([ self::assertEquals([
'referer' => 'some site', 'referer' => 'some site',
@ -48,7 +48,7 @@ class VisitTest extends TestCase
public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void
{ {
$visit = Visit::forValidShortUrl( $visit = Visit::forValidShortUrl(
ShortUrl::createEmpty(), ShortUrl::createFake(),
new Visitor('Chrome', 'some site', $address, ''), new Visitor('Chrome', 'some site', $address, ''),
$anonymize, $anonymize,
); );

View file

@ -86,7 +86,7 @@ class VisitsStatsHelperTest extends TestCase
$repo = $this->createMock(ShortUrlRepositoryInterface::class); $repo = $this->createMock(ShortUrlRepositoryInterface::class);
$repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true);
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()));
$repo2 = $this->createMock(VisitRepository::class); $repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByShortCode')->with( $repo2->method('findVisitsByShortCode')->with(
$identifier, $identifier,
@ -146,7 +146,7 @@ class VisitsStatsHelperTest extends TestCase
$repo = $this->createMock(TagRepository::class); $repo = $this->createMock(TagRepository::class);
$repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true);
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()));
$repo2 = $this->createMock(VisitRepository::class); $repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn( $repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn(
$list, $list,
@ -187,7 +187,7 @@ class VisitsStatsHelperTest extends TestCase
$repo = $this->createMock(DomainRepository::class); $repo = $this->createMock(DomainRepository::class);
$repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true);
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()));
$repo2 = $this->createMock(VisitRepository::class); $repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByDomain')->with( $repo2->method('findVisitsByDomain')->with(
$domain, $domain,
@ -217,7 +217,7 @@ class VisitsStatsHelperTest extends TestCase
$repo = $this->createMock(DomainRepository::class); $repo = $this->createMock(DomainRepository::class);
$repo->expects($this->never())->method('domainExists'); $repo->expects($this->never())->method('domainExists');
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()));
$repo2 = $this->createMock(VisitRepository::class); $repo2 = $this->createMock(VisitRepository::class);
$repo2->method('findVisitsByDomain')->with( $repo2->method('findVisitsByDomain')->with(
'DEFAULT', 'DEFAULT',
@ -259,7 +259,7 @@ class VisitsStatsHelperTest extends TestCase
/** @test */ /** @test */
public function nonOrphanVisitsAreReturnedAsExpected(): void public function nonOrphanVisitsAreReturnedAsExpected(): void
{ {
$list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()));
$repo = $this->createMock(VisitRepository::class); $repo = $this->createMock(VisitRepository::class);
$repo->expects($this->once())->method('countNonOrphanVisits')->with( $repo->expects($this->once())->method('countNonOrphanVisits')->with(
$this->isInstanceOf(VisitsCountFiltering::class), $this->isInstanceOf(VisitsCountFiltering::class),

View file

@ -58,7 +58,7 @@ class VisitsTrackerTest extends TestCase
public function provideTrackingMethodNames(): iterable public function provideTrackingMethodNames(): iterable
{ {
yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; yield 'track' => ['track', [ShortUrl::createFake(), Visitor::emptyInstance()]];
yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]];
yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]];
yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]];

View file

@ -22,7 +22,7 @@ final class RoleDefinition
{ {
return new self( return new self(
Role::DOMAIN_SPECIFIC, Role::DOMAIN_SPECIFIC,
['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], ['domain_id' => $domain->getId(), 'authority' => $domain->authority],
); );
} }
} }

View file

@ -21,7 +21,7 @@ class ApiKey extends AbstractEntity
private string $key; private string $key;
private ?Chronos $expirationDate = null; private ?Chronos $expirationDate = null;
private bool $enabled; private bool $enabled;
/** @var Collection|ApiKeyRole[] */ /** @var Collection<string, ApiKeyRole> */
private Collection $roles; private Collection $roles;
private ?string $name = null; private ?string $name = null;
@ -147,12 +147,9 @@ class ApiKey extends AbstractEntity
$meta = $roleDefinition->meta; $meta = $roleDefinition->meta;
if ($this->hasRole($role)) { if ($this->hasRole($role)) {
/** @var ApiKeyRole $apiKeyRole */ $this->roles->get($role->value)?->updateMeta($meta);
$apiKeyRole = $this->roles->get($role->value);
$apiKeyRole->updateMeta($meta);
} else { } else {
$apiKeyRole = new ApiKeyRole($roleDefinition->role, $roleDefinition->meta, $this); $this->roles->set($role->value, new ApiKeyRole($role, $meta, $this));
$this->roles[$role->value] = $apiKeyRole;
} }
} }
} }

View file

@ -34,11 +34,11 @@ class OverrideDomainMiddleware implements MiddlewareInterface
if ($requestMethod === RequestMethodInterface::METHOD_POST) { if ($requestMethod === RequestMethodInterface::METHOD_POST) {
/** @var array $payload */ /** @var array $payload */
$payload = $request->getParsedBody(); $payload = $request->getParsedBody();
$payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); $payload[ShortUrlInputFilter::DOMAIN] = $domain->authority;
return $handler->handle($request->withParsedBody($payload)); return $handler->handle($request->withParsedBody($payload));
} }
return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->getAuthority())); return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority));
} }
} }

View file

@ -118,7 +118,7 @@ class CreateShortUrlTest extends ApiTestCase
public function provideMaxVisits(): array public function provideMaxVisits(): array
{ {
return map(range(10, 15), fn (int $i) => [$i]); return map(range(10, 15), fn(int $i) => [$i]);
} }
/** @test */ /** @test */
@ -172,12 +172,14 @@ class CreateShortUrlTest extends ApiTestCase
yield 'only long URL' => [['longUrl' => $longUrl]]; yield 'only long URL' => [['longUrl' => $longUrl]];
yield 'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]]; yield 'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]];
yield 'long URL and custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']]; yield 'long URL and custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']];
yield 'several params' => [[ yield 'several params' => [
'longUrl' => $longUrl, [
'tags' => ['boo', 'far'], 'longUrl' => $longUrl,
'validSince' => Chronos::now()->toAtomString(), 'tags' => ['boo', 'far'],
'maxVisits' => 7, 'validSince' => Chronos::now()->toAtomString(),
]]; 'maxVisits' => 7,
],
];
} }
/** /**
@ -261,21 +263,20 @@ class CreateShortUrlTest extends ApiTestCase
public function provideInvalidUrls(): iterable public function provideInvalidUrls(): iterable
{ {
yield 'empty URL' => ['', '2', 'INVALID_URL']; yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL'];
yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url'];
yield 'API version 3' => ['', '3', 'https://shlink.io/api/error/invalid-url'];
} }
/** /**
* @test * @test
* @dataProvider provideInvalidArgumentApiVersions * @dataProvider provideInvalidArgumentApiVersions
*/ */
public function failsToCreateShortUrlWithoutLongUrl(string $version, string $expectedType): void public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void
{ {
$resp = $this->callApiWithKey( $resp = $this->callApiWithKey(
self::METHOD_POST, self::METHOD_POST,
sprintf('/rest/v%s/short-urls', $version), sprintf('/rest/v%s/short-urls', $version),
[RequestOptions::JSON => []], [RequestOptions::JSON => $payload],
); );
$payload = $this->getJsonResponsePayload($resp); $payload = $this->getJsonResponsePayload($resp);
@ -288,8 +289,22 @@ class CreateShortUrlTest extends ApiTestCase
public function provideInvalidArgumentApiVersions(): iterable public function provideInvalidArgumentApiVersions(): iterable
{ {
yield ['2', 'INVALID_ARGUMENT']; yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT'];
yield ['3', 'https://shlink.io/api/error/invalid-data']; yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT'];
yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'empty device long url v2' => [[
'longUrl' => 'foo',
'deviceLongUrls' => [
'android' => null,
],
], '2', 'INVALID_ARGUMENT'];
yield 'empty device long url v3' => [[
'longUrl' => 'foo',
'deviceLongUrls' => [
'ios' => ' ',
],
], '3', 'https://shlink.io/api/error/invalid-data'];
} }
/** @test */ /** @test */
@ -362,6 +377,22 @@ class CreateShortUrlTest extends ApiTestCase
self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']); self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']);
} }
/** @test */
public function canCreateShortUrlsWithDeviceLongUrls(): void
{
[$statusCode, $payload] = $this->createShortUrl([
'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557',
'deviceLongUrls' => [
'ios' => 'https://github.com/shlinkio/shlink/ios',
'android' => 'https://github.com/shlinkio/shlink/android',
],
]);
self::assertEquals(self::STATUS_OK, $statusCode);
self::assertEquals('https://github.com/shlinkio/shlink/ios', $payload['deviceLongUrls']['ios'] ?? null);
self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null);
}
/** /**
* @return array{int, array} * @return array{int, array}
*/ */

View file

@ -154,7 +154,7 @@ class EditShortUrlTest extends ApiTestCase
$editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [
'maxVisits' => 100, 'maxVisits' => 100,
]]); ]]);
$editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url)); $editedShortUrl = $this->getJsonResponsePayload($editResp);
self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); self::assertEquals(self::STATUS_OK, $editResp->getStatusCode());
self::assertEquals($domain, $editedShortUrl['domain']); self::assertEquals($domain, $editedShortUrl['domain']);
@ -170,4 +170,27 @@ class EditShortUrlTest extends ApiTestCase
]; ];
yield 'no domain' => [null, 'https://shlink.io/documentation/']; yield 'no domain' => [null, 'https://shlink.io/documentation/'];
} }
/** @test */
public function deviceLongUrlsCanBeEdited(): void
{
$shortCode = 'def456';
$url = new Uri(sprintf('/short-urls/%s', $shortCode));
$editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [
'deviceLongUrls' => [
'android' => null, // This one will get removed
'ios' => 'https://blog.alejandrocelaya.com/ios/edited', // This one will be edited
'desktop' => 'https://blog.alejandrocelaya.com/desktop', // This one is new and will be created
],
]]);
$deviceLongUrls = $this->getJsonResponsePayload($editResp)['deviceLongUrls'] ?? [];
self::assertEquals(self::STATUS_OK, $editResp->getStatusCode());
self::assertArrayHasKey('ios', $deviceLongUrls);
self::assertEquals('https://blog.alejandrocelaya.com/ios/edited', $deviceLongUrls['ios']);
self::assertArrayHasKey('desktop', $deviceLongUrls);
self::assertEquals('https://blog.alejandrocelaya.com/desktop', $deviceLongUrls['desktop']);
self::assertArrayHasKey('android', $deviceLongUrls);
self::assertNull($deviceLongUrls['android']);
}
} }

View file

@ -6,6 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions; use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function count; use function count;
@ -169,109 +170,124 @@ class ListShortUrlsTest extends ApiTestCase
public function provideFilteredLists(): iterable public function provideFilteredLists(): iterable
{ {
// FIXME Cannot use enums in constants in PHP 8.1. Change this once support for PHP 8.1 is dropped
$withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [
...$shortUrl,
'deviceLongUrls' => $longUrls ?? [
DeviceType::ANDROID->value => null,
DeviceType::IOS->value => null,
DeviceType::DESKTOP->value => null,
],
];
$shortUrlMeta = $withDeviceLongUrls(self::SHORT_URL_META, [
DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android',
DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios',
DeviceType::DESKTOP->value => null,
]);
yield [[], [ yield [[], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['excludePastValidUntil' => 'true'], [ yield [['excludePastValidUntil' => 'true'], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['excludeMaxVisitsReached' => 'true'], [ yield [['excludeMaxVisitsReached' => 'true'], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['orderBy' => 'shortCode'], [ yield [['orderBy' => 'shortCode'], [
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['orderBy' => 'shortCode-DESC'], [ yield [['orderBy' => 'shortCode-DESC'], [
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['orderBy' => 'title-DESC'], [ yield [['orderBy' => 'title-DESC'], [
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_META, $shortUrlMeta,
], 'valid_api_key']; ], 'valid_api_key'];
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN),
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
self::SHORT_URL_DOCS, $withDeviceLongUrls(self::SHORT_URL_DOCS),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo']], [ yield [['tags' => ['foo']], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['bar']], [ yield [['tags' => ['bar']], [
self::SHORT_URL_META, $shortUrlMeta,
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo', 'bar']], [ yield [['tags' => ['foo', 'bar']], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [
self::SHORT_URL_META, $shortUrlMeta,
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo', 'bar', 'baz']], [ yield [['tags' => ['foo', 'bar', 'baz']], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key'];
yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['searchTerm' => 'alejandro'], [ yield [['searchTerm' => 'alejandro'], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
self::SHORT_URL_META, $shortUrlMeta,
], 'valid_api_key']; ], 'valid_api_key'];
yield [['searchTerm' => 'cool'], [ yield [['searchTerm' => 'cool'], [
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'valid_api_key']; ], 'valid_api_key'];
yield [['searchTerm' => 'example.com'], [ yield [['searchTerm' => 'example.com'], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
], 'valid_api_key']; ], 'valid_api_key'];
yield [[], [ yield [[], [
self::SHORT_URL_CUSTOM_SLUG, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG),
self::SHORT_URL_META, $shortUrlMeta,
self::SHORT_URL_SHLINK_WITH_TITLE, $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE),
], 'author_api_key']; ], 'author_api_key'];
yield [[], [ yield [[], [
self::SHORT_URL_CUSTOM_DOMAIN, $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN),
], 'domain_api_key']; ], 'domain_api_key'];
} }

Some files were not shown because too many files have changed in this diff Show more