CreateShortUrlTest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. <?php
  2. declare(strict_types=1);
  3. namespace ShlinkioApiTest\Shlink\Rest\Action;
  4. use Cake\Chronos\Chronos;
  5. use GuzzleHttp\RequestOptions;
  6. use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
  7. use function Functional\map;
  8. use function range;
  9. use function sprintf;
  10. class CreateShortUrlTest extends ApiTestCase
  11. {
  12. /** @test */
  13. public function createsNewShortUrlWhenOnlyLongUrlIsProvided(): void
  14. {
  15. $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags'];
  16. [$statusCode, $payload] = $this->createShortUrl();
  17. self::assertEquals(self::STATUS_OK, $statusCode);
  18. foreach ($expectedKeys as $key) {
  19. self::assertArrayHasKey($key, $payload);
  20. }
  21. }
  22. /** @test */
  23. public function createsNewShortUrlWithCustomSlug(): void
  24. {
  25. [$statusCode, $payload] = $this->createShortUrl(['customSlug' => 'my cool slug']);
  26. self::assertEquals(self::STATUS_OK, $statusCode);
  27. self::assertEquals('my-cool-slug', $payload['shortCode']);
  28. }
  29. /**
  30. * @test
  31. * @dataProvider provideConflictingSlugs
  32. */
  33. public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, ?string $domain): void
  34. {
  35. $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
  36. $detail = sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix);
  37. [$statusCode, $payload] = $this->createShortUrl(['customSlug' => $slug, 'domain' => $domain]);
  38. self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
  39. self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
  40. self::assertEquals($detail, $payload['detail']);
  41. self::assertEquals('INVALID_SLUG', $payload['type']);
  42. self::assertEquals('Invalid custom slug', $payload['title']);
  43. self::assertEquals($slug, $payload['customSlug']);
  44. if ($domain !== null) {
  45. self::assertEquals($domain, $payload['domain']);
  46. } else {
  47. self::assertArrayNotHasKey('domain', $payload);
  48. }
  49. }
  50. /**
  51. * @test
  52. * @dataProvider provideTags
  53. */
  54. public function createsNewShortUrlWithTags(array $providedTags, array $expectedTags): void
  55. {
  56. [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => $providedTags]);
  57. self::assertEquals(self::STATUS_OK, $statusCode);
  58. self::assertEquals($expectedTags, $tags);
  59. }
  60. public function provideTags(): iterable
  61. {
  62. yield 'simple tags' => [$simpleTags = ['foo', 'bar', 'baz'], $simpleTags];
  63. yield 'tags with spaces' => [['fo o', ' bar', 'b az'], ['fo-o', 'bar', 'b-az']];
  64. yield 'tags with special chars' => [['UUU', 'Aäa'], ['uuu', 'aäa']];
  65. }
  66. /**
  67. * @test
  68. * @dataProvider provideMaxVisits
  69. */
  70. public function createsNewShortUrlWithVisitsLimit(int $maxVisits): void
  71. {
  72. [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl(['maxVisits' => $maxVisits]);
  73. self::assertEquals(self::STATUS_OK, $statusCode);
  74. // Last request to the short URL will return a 404, and the rest, a 302
  75. for ($i = 0; $i < $maxVisits; $i++) {
  76. self::assertEquals(self::STATUS_FOUND, $this->callShortUrl($shortCode)->getStatusCode());
  77. }
  78. $lastResp = $this->callShortUrl($shortCode);
  79. self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
  80. }
  81. public function provideMaxVisits(): array
  82. {
  83. return map(range(10, 15), fn (int $i) => [$i]);
  84. }
  85. /** @test */
  86. public function createsShortUrlWithValidSince(): void
  87. {
  88. [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
  89. 'validSince' => Chronos::now()->addDay()->toAtomString(),
  90. ]);
  91. self::assertEquals(self::STATUS_OK, $statusCode);
  92. // Request to the short URL will return a 404 since it's not valid yet
  93. $lastResp = $this->callShortUrl($shortCode);
  94. self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
  95. }
  96. /** @test */
  97. public function createsShortUrlWithValidUntil(): void
  98. {
  99. [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
  100. 'validUntil' => Chronos::now()->subDay()->toAtomString(),
  101. ]);
  102. self::assertEquals(self::STATUS_OK, $statusCode);
  103. // Request to the short URL will return a 404 since it's no longer valid
  104. $lastResp = $this->callShortUrl($shortCode);
  105. self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
  106. }
  107. /**
  108. * @test
  109. * @dataProvider provideMatchingBodies
  110. */
  111. public function returnsAnExistingShortUrlWhenRequested(array $body): void
  112. {
  113. [$firstStatusCode, ['shortCode' => $firstShortCode]] = $this->createShortUrl($body);
  114. $body['findIfExists'] = true;
  115. [$secondStatusCode, ['shortCode' => $secondShortCode]] = $this->createShortUrl($body);
  116. self::assertEquals(self::STATUS_OK, $firstStatusCode);
  117. self::assertEquals(self::STATUS_OK, $secondStatusCode);
  118. self::assertEquals($firstShortCode, $secondShortCode);
  119. }
  120. public function provideMatchingBodies(): iterable
  121. {
  122. $longUrl = 'https://www.alejandrocelaya.com';
  123. yield 'only long URL' => [['longUrl' => $longUrl]];
  124. yield 'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]];
  125. yield 'long URL and custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']];
  126. yield 'several params' => [[
  127. 'longUrl' => $longUrl,
  128. 'tags' => ['boo', 'far'],
  129. 'validSince' => Chronos::now()->toAtomString(),
  130. 'maxVisits' => 7,
  131. ]];
  132. }
  133. /**
  134. * @test
  135. * @dataProvider provideConflictingSlugs
  136. */
  137. public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(string $slug, ?string $domain): void
  138. {
  139. $longUrl = 'https://www.alejandrocelaya.com';
  140. [$firstStatusCode] = $this->createShortUrl(['longUrl' => $longUrl]);
  141. [$secondStatusCode] = $this->createShortUrl([
  142. 'longUrl' => $longUrl,
  143. 'customSlug' => $slug,
  144. 'findIfExists' => true,
  145. 'domain' => $domain,
  146. ]);
  147. self::assertEquals(self::STATUS_OK, $firstStatusCode);
  148. self::assertEquals(self::STATUS_BAD_REQUEST, $secondStatusCode);
  149. }
  150. public function provideConflictingSlugs(): iterable
  151. {
  152. yield 'without domain' => ['custom', null];
  153. yield 'with domain' => ['custom-with-domain', 'some-domain.com'];
  154. }
  155. /** @test */
  156. public function createsNewShortUrlIfRequestedToFindButThereIsNoMatch(): void
  157. {
  158. [$firstStatusCode, ['shortCode' => $firstShortCode]] = $this->createShortUrl([
  159. 'longUrl' => 'https://www.alejandrocelaya.com',
  160. ]);
  161. [$secondStatusCode, ['shortCode' => $secondShortCode]] = $this->createShortUrl([
  162. 'longUrl' => 'https://www.alejandrocelaya.com/projects',
  163. 'findIfExists' => true,
  164. ]);
  165. self::assertEquals(self::STATUS_OK, $firstStatusCode);
  166. self::assertEquals(self::STATUS_OK, $secondStatusCode);
  167. self::assertNotEquals($firstShortCode, $secondShortCode);
  168. }
  169. /**
  170. * @test
  171. * @dataProvider provideIdn
  172. */
  173. public function createsNewShortUrlWithInternationalizedDomainName(string $longUrl): void
  174. {
  175. [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $longUrl]);
  176. self::assertEquals(self::STATUS_OK, $statusCode);
  177. self::assertEquals($payload['longUrl'], $longUrl);
  178. }
  179. public function provideIdn(): iterable
  180. {
  181. yield ['http://tést.shlink.io']; // Redirects to https://shlink.io
  182. yield ['http://test.shlink.io']; // Redirects to http://tést.shlink.io
  183. yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io
  184. }
  185. /**
  186. * @test
  187. * @dataProvider provideInvalidUrls
  188. */
  189. public function failsToCreateShortUrlWithInvalidLongUrl(string $url): void
  190. {
  191. $expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
  192. [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url]);
  193. self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
  194. self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
  195. self::assertEquals('INVALID_URL', $payload['type']);
  196. self::assertEquals($expectedDetail, $payload['detail']);
  197. self::assertEquals('Invalid URL', $payload['title']);
  198. self::assertEquals($url, $payload['url']);
  199. }
  200. public function provideInvalidUrls(): iterable
  201. {
  202. yield 'empty URL' => [''];
  203. yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com'];
  204. }
  205. /** @test */
  206. public function failsToCreateShortUrlWithoutLongUrl(): void
  207. {
  208. $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => []]);
  209. $payload = $this->getJsonResponsePayload($resp);
  210. self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
  211. self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
  212. self::assertEquals('INVALID_ARGUMENT', $payload['type']);
  213. self::assertEquals('Provided data is not valid', $payload['detail']);
  214. self::assertEquals('Invalid data', $payload['title']);
  215. }
  216. /** @test */
  217. public function defaultDomainIsDroppedIfProvided(): void
  218. {
  219. [$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
  220. 'longUrl' => 'https://www.alejandrocelaya.com',
  221. 'domain' => 'doma.in',
  222. ]);
  223. $getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode);
  224. $payload = $this->getJsonResponsePayload($getResp);
  225. self::assertEquals(self::STATUS_OK, $createStatusCode);
  226. self::assertEquals(self::STATUS_OK, $getResp->getStatusCode());
  227. self::assertArrayHasKey('domain', $payload);
  228. self::assertNull($payload['domain']);
  229. }
  230. /**
  231. * @test
  232. * @dataProvider provideDomains
  233. */
  234. public function apiKeyDomainIsEnforced(?string $providedDomain): void
  235. {
  236. [$statusCode, ['domain' => $returnedDomain]] = $this->createShortUrl(
  237. ['domain' => $providedDomain],
  238. 'domain_api_key',
  239. );
  240. self::assertEquals(self::STATUS_OK, $statusCode);
  241. self::assertEquals('example.com', $returnedDomain);
  242. }
  243. public function provideDomains(): iterable
  244. {
  245. yield 'no domain' => [null];
  246. yield 'invalid domain' => ['this-will-be-overwritten.com'];
  247. yield 'example domain' => ['example.com'];
  248. }
  249. /**
  250. * @test
  251. * @dataProvider provideTwitterUrls
  252. */
  253. public function urlsWithBothProtectionCanBeShortenedWithUrlValidationEnabled(string $longUrl): void
  254. {
  255. [$statusCode] = $this->createShortUrl(['longUrl' => $longUrl, 'validateUrl' => true]);
  256. self::assertEquals(self::STATUS_OK, $statusCode);
  257. }
  258. public function provideTwitterUrls(): iterable
  259. {
  260. yield ['https://twitter.com/shlinkio'];
  261. yield ['https://mobile.twitter.com/shlinkio'];
  262. yield ['https://twitter.com/shlinkio/status/1360637738421268481'];
  263. yield ['https://mobile.twitter.com/shlinkio/status/1360637738421268481'];
  264. }
  265. /**
  266. * @return array {
  267. * @var int $statusCode
  268. * @var array $payload
  269. * }
  270. */
  271. private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array
  272. {
  273. if (! isset($body['longUrl'])) {
  274. $body['longUrl'] = 'https://app.shlink.io';
  275. }
  276. $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body], $apiKey);
  277. $payload = $this->getJsonResponsePayload($resp);
  278. return [$resp->getStatusCode(), $payload];
  279. }
  280. }