UrlShortenerTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <?php
  2. declare(strict_types=1);
  3. namespace ShlinkioTest\Shlink\Core\Service;
  4. use Cake\Chronos\Chronos;
  5. use Doctrine\Common\Collections\ArrayCollection;
  6. use Doctrine\DBAL\Connection;
  7. use Doctrine\ORM\EntityManagerInterface;
  8. use Doctrine\ORM\ORMException;
  9. use Laminas\Diactoros\Uri;
  10. use PHPUnit\Framework\TestCase;
  11. use Prophecy\Argument;
  12. use Prophecy\Prophecy\ObjectProphecy;
  13. use Shlinkio\Shlink\Core\Entity\ShortUrl;
  14. use Shlinkio\Shlink\Core\Entity\Tag;
  15. use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
  16. use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
  17. use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
  18. use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
  19. use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
  20. use Shlinkio\Shlink\Core\Service\UrlShortener;
  21. use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
  22. use function array_map;
  23. class UrlShortenerTest extends TestCase
  24. {
  25. private UrlShortener $urlShortener;
  26. private ObjectProphecy $em;
  27. private ObjectProphecy $urlValidator;
  28. public function setUp(): void
  29. {
  30. $this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
  31. $this->em = $this->prophesize(EntityManagerInterface::class);
  32. $conn = $this->prophesize(Connection::class);
  33. $conn->isTransactionActive()->willReturn(false);
  34. $this->em->getConnection()->willReturn($conn->reveal());
  35. $this->em->flush()->willReturn(null);
  36. $this->em->commit()->willReturn(null);
  37. $this->em->beginTransaction()->willReturn(null);
  38. $this->em->persist(Argument::any())->will(function ($arguments): void {
  39. /** @var ShortUrl $shortUrl */
  40. [$shortUrl] = $arguments;
  41. $shortUrl->setId('10');
  42. });
  43. $repo = $this->prophesize(ShortUrlRepository::class);
  44. $repo->shortCodeIsInUse(Argument::cetera())->willReturn(false);
  45. $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  46. $this->setUrlShortener(false);
  47. }
  48. private function setUrlShortener(bool $urlValidationEnabled): void
  49. {
  50. $this->urlShortener = new UrlShortener(
  51. $this->urlValidator->reveal(),
  52. $this->em->reveal(),
  53. new UrlShortenerOptions(['validate_url' => $urlValidationEnabled]),
  54. );
  55. }
  56. /** @test */
  57. public function urlIsProperlyShortened(): void
  58. {
  59. $shortUrl = $this->urlShortener->urlToShortCode(
  60. new Uri('http://foobar.com/12345/hello?foo=bar'),
  61. [],
  62. ShortUrlMeta::createEmpty(),
  63. );
  64. $this->assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl());
  65. }
  66. /** @test */
  67. public function shortCodeIsRegeneratedIfAlreadyInUse(): void
  68. {
  69. $callIndex = 0;
  70. $expectedCalls = 3;
  71. $repo = $this->prophesize(ShortUrlRepository::class);
  72. $shortCodeIsInUse = $repo->shortCodeIsInUse(Argument::cetera())->will(
  73. function () use (&$callIndex, $expectedCalls) {
  74. $callIndex++;
  75. return $callIndex < $expectedCalls;
  76. },
  77. );
  78. $repo->findBy(Argument::cetera())->willReturn([]);
  79. $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  80. $shortUrl = $this->urlShortener->urlToShortCode(
  81. new Uri('http://foobar.com/12345/hello?foo=bar'),
  82. [],
  83. ShortUrlMeta::createEmpty(),
  84. );
  85. $this->assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl());
  86. $getRepo->shouldBeCalledTimes($expectedCalls);
  87. $shortCodeIsInUse->shouldBeCalledTimes($expectedCalls);
  88. }
  89. /** @test */
  90. public function transactionIsRolledBackAndExceptionRethrownWhenExceptionIsThrown(): void
  91. {
  92. $conn = $this->prophesize(Connection::class);
  93. $conn->isTransactionActive()->willReturn(true);
  94. $this->em->getConnection()->willReturn($conn->reveal());
  95. $this->em->rollback()->shouldBeCalledOnce();
  96. $this->em->close()->shouldBeCalledOnce();
  97. $this->em->flush()->willThrow(new ORMException());
  98. $this->expectException(ORMException::class);
  99. $this->urlShortener->urlToShortCode(
  100. new Uri('http://foobar.com/12345/hello?foo=bar'),
  101. [],
  102. ShortUrlMeta::createEmpty(),
  103. );
  104. }
  105. /** @test */
  106. public function validatorIsCalledWhenUrlValidationIsEnabled(): void
  107. {
  108. $this->setUrlShortener(true);
  109. $validateUrl = $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(
  110. function (): void {
  111. },
  112. );
  113. $this->urlShortener->urlToShortCode(
  114. new Uri('http://foobar.com/12345/hello?foo=bar'),
  115. [],
  116. ShortUrlMeta::createEmpty(),
  117. );
  118. $validateUrl->shouldHaveBeenCalledOnce();
  119. }
  120. /** @test */
  121. public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
  122. {
  123. $repo = $this->prophesize(ShortUrlRepository::class);
  124. $shortCodeIsInUse = $repo->shortCodeIsInUse('custom-slug', null)->willReturn(true);
  125. $repo->findBy(Argument::cetera())->willReturn([]);
  126. $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  127. $shortCodeIsInUse->shouldBeCalledOnce();
  128. $getRepo->shouldBeCalled();
  129. $this->expectException(NonUniqueSlugException::class);
  130. $this->urlShortener->urlToShortCode(
  131. new Uri('http://foobar.com/12345/hello?foo=bar'),
  132. [],
  133. ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug']),
  134. );
  135. }
  136. /**
  137. * @test
  138. * @dataProvider provideExistingShortUrls
  139. */
  140. public function existingShortUrlIsReturnedWhenRequested(
  141. string $url,
  142. array $tags,
  143. ShortUrlMeta $meta,
  144. ShortUrl $expected
  145. ): void {
  146. $repo = $this->prophesize(ShortUrlRepository::class);
  147. $findExisting = $repo->findBy(Argument::any())->willReturn([$expected]);
  148. $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  149. $result = $this->urlShortener->urlToShortCode(new Uri($url), $tags, $meta);
  150. $findExisting->shouldHaveBeenCalledOnce();
  151. $getRepo->shouldHaveBeenCalledOnce();
  152. $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
  153. $this->assertSame($expected, $result);
  154. }
  155. public function provideExistingShortUrls(): iterable
  156. {
  157. $url = 'http://foo.com';
  158. yield [$url, [], ShortUrlMeta::createFromRawData(['findIfExists' => true]), new ShortUrl($url)];
  159. yield [$url, [], ShortUrlMeta::createFromRawData(
  160. ['findIfExists' => true, 'customSlug' => 'foo'],
  161. ), new ShortUrl($url)];
  162. yield [
  163. $url,
  164. ['foo', 'bar'],
  165. ShortUrlMeta::createFromRawData(['findIfExists' => true]),
  166. (new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])),
  167. ];
  168. yield [
  169. $url,
  170. [],
  171. ShortUrlMeta::createFromRawData(['findIfExists' => true, 'maxVisits' => 3]),
  172. new ShortUrl($url, ShortUrlMeta::createFromRawData(['maxVisits' => 3])),
  173. ];
  174. yield [
  175. $url,
  176. [],
  177. ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]),
  178. new ShortUrl($url, ShortUrlMeta::createFromRawData(['validSince' => Chronos::parse('2017-01-01')])),
  179. ];
  180. yield [
  181. $url,
  182. [],
  183. ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]),
  184. new ShortUrl($url, ShortUrlMeta::createFromRawData(['validUntil' => Chronos::parse('2017-01-01')])),
  185. ];
  186. yield [
  187. $url,
  188. [],
  189. ShortUrlMeta::createFromRawData(['findIfExists' => true, 'domain' => 'example.com']),
  190. new ShortUrl($url, ShortUrlMeta::createFromRawData(['domain' => 'example.com'])),
  191. ];
  192. yield [
  193. $url,
  194. ['baz', 'foo', 'bar'],
  195. ShortUrlMeta::createFromRawData([
  196. 'findIfExists' => true,
  197. 'validUntil' => Chronos::parse('2017-01-01'),
  198. 'maxVisits' => 4,
  199. ]),
  200. (new ShortUrl($url, ShortUrlMeta::createFromRawData([
  201. 'validUntil' => Chronos::parse('2017-01-01'),
  202. 'maxVisits' => 4,
  203. ])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])),
  204. ];
  205. }
  206. /** @test */
  207. public function properExistingShortUrlIsReturnedWhenMultipleMatch(): void
  208. {
  209. $url = 'http://foo.com';
  210. $tags = ['baz', 'foo', 'bar'];
  211. $meta = ShortUrlMeta::createFromRawData([
  212. 'findIfExists' => true,
  213. 'validUntil' => Chronos::parse('2017-01-01'),
  214. 'maxVisits' => 4,
  215. ]);
  216. $tagsCollection = new ArrayCollection(array_map(fn (string $tag) => new Tag($tag), $tags));
  217. $expected = (new ShortUrl($url, $meta))->setTags($tagsCollection);
  218. $repo = $this->prophesize(ShortUrlRepository::class);
  219. $findExisting = $repo->findBy(Argument::any())->willReturn([
  220. new ShortUrl($url),
  221. new ShortUrl($url, $meta),
  222. $expected,
  223. (new ShortUrl($url))->setTags($tagsCollection),
  224. ]);
  225. $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  226. $result = $this->urlShortener->urlToShortCode(new Uri($url), $tags, $meta);
  227. $this->assertSame($expected, $result);
  228. $findExisting->shouldHaveBeenCalledOnce();
  229. $getRepo->shouldHaveBeenCalledOnce();
  230. }
  231. /** @test */
  232. public function shortCodeIsProperlyParsed(): void
  233. {
  234. $shortUrl = new ShortUrl('expected_url');
  235. $shortCode = $shortUrl->getShortCode();
  236. $repo = $this->prophesize(ShortUrlRepositoryInterface::class);
  237. $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
  238. $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
  239. $url = $this->urlShortener->shortCodeToUrl($shortCode);
  240. $this->assertSame($shortUrl, $url);
  241. }
  242. }