diff --git a/CHANGELOG.md b/CHANGELOG.md index 62daac9..db67b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [0.1.0] - 2021-12-04 ### Added -* *Nothing* +* First release ### Changed * *Nothing* diff --git a/composer.json b/composer.json index 98cff66..add6fe2 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "test": "phpdbg -qrr vendor/bin/phpunit --order-by=random --testdox --colors=always", "test:ci": "@test --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml", "test:pretty": "@test --coverage-html build/coverage-html", - "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --only-covering-test-cases", + "infect": "infection --threads=4 --min-msi=90 --log-verbosity=default --only-covered --only-covering-test-cases", "infect:ci": "@infect --coverage=build --skip-initial-tests", "infect:show": "@infect --show-mutations", "infect:show:ci": "@infect --show-mutations --coverage=build", diff --git a/src/ShortUrls/ShortUrlsClient.php b/src/ShortUrls/ShortUrlsClient.php index 7e234d3..5b08f19 100644 --- a/src/ShortUrls/ShortUrlsClient.php +++ b/src/ShortUrls/ShortUrlsClient.php @@ -39,8 +39,8 @@ public function listShortUrls(): ShortUrlsList */ public function listShortUrlsWithFilter(ShortUrlsFilter $filter): ShortUrlsList { - $buildQueryWithPage = static function (int $page) use ($filter): array { - $query = $filter->toArray(); + $query = $filter->toArray(); + $buildQueryWithPage = static function (int $page) use ($query): array { $query['itemsPerPage'] = ShortUrlsList::ITEMS_PER_PAGE; $query['page'] = $page; diff --git a/test/ShortUrls/Exception/DeleteShortUrlThresholdExceptionTest.php b/test/ShortUrls/Exception/DeleteShortUrlThresholdExceptionTest.php new file mode 100644 index 0000000..3de50ce --- /dev/null +++ b/test/ShortUrls/Exception/DeleteShortUrlThresholdExceptionTest.php @@ -0,0 +1,48 @@ +identifier()); + self::assertEquals($expectedThreshold, $e->threshold()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedCode, $e->getCode()); + } + + public function provideExceptions(): iterable + { + yield [HttpException::fromPayload([]), ShortUrlIdentifier::fromShortCode(''), 0, '', -1]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 404, + ]), ShortUrlIdentifier::fromShortCode(''), 0, $message, $code]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 400, + 'shortCode' => $shortCode = 'foo', + 'domain' => $domain = 'doma.in', + 'threshold' => $threshold = 15, + ]), ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), $threshold, $message, $code]; + } +} diff --git a/test/ShortUrls/Exception/InvalidLongUrlExceptionTest.php b/test/ShortUrls/Exception/InvalidLongUrlExceptionTest.php new file mode 100644 index 0000000..4a9848b --- /dev/null +++ b/test/ShortUrls/Exception/InvalidLongUrlExceptionTest.php @@ -0,0 +1,43 @@ +longUrl()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedCode, $e->getCode()); + } + + public function provideExceptions(): iterable + { + yield [HttpException::fromPayload([]), '', '', -1]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 400, + ]), '', $message, $code]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 404, + 'url' => $url = 'https://foo.com/baz', + ]), $url, $message, $code]; + } +} diff --git a/test/ShortUrls/Exception/NonUniqueSlugExceptionTest.php b/test/ShortUrls/Exception/NonUniqueSlugExceptionTest.php new file mode 100644 index 0000000..0ef657a --- /dev/null +++ b/test/ShortUrls/Exception/NonUniqueSlugExceptionTest.php @@ -0,0 +1,46 @@ +customSlug()); + self::assertEquals($expectedDomain, $e->domain()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedCode, $e->getCode()); + } + + public function provideExceptions(): iterable + { + yield [HttpException::fromPayload([]), '', null, '', -1]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 400, + ]), '', null, $message, $code]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 404, + 'customSlug' => $customSlug = 'baz', + 'domain' => $domain = 'doma.in', + ]), $customSlug, $domain, $message, $code]; + } +} diff --git a/test/ShortUrls/Exception/ShortUrlNotFoundExceptionTest.php b/test/ShortUrls/Exception/ShortUrlNotFoundExceptionTest.php new file mode 100644 index 0000000..6704e06 --- /dev/null +++ b/test/ShortUrls/Exception/ShortUrlNotFoundExceptionTest.php @@ -0,0 +1,50 @@ +identifier()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedCode, $e->getCode()); + } + + public function provideExceptions(): iterable + { + yield [HttpException::fromPayload([]), ShortUrlIdentifier::fromShortCode(''), '', -1]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 404, + ]), ShortUrlIdentifier::fromShortCode(''), $message, $code]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 400, + 'shortCode' => $shortCode = 'foo', + ]), ShortUrlIdentifier::fromShortCode($shortCode), $message, $code]; + yield [HttpException::fromPayload([ + 'detail' => $message = 'This is the message', + 'status' => $code = 400, + 'shortCode' => $shortCode = 'foo', + 'domain' => $domain = 'doma.in', + ]), ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), $message, $code]; + } +} diff --git a/test/ShortUrls/Model/ShortUrlCreationTest.php b/test/ShortUrls/Model/ShortUrlCreationTest.php new file mode 100644 index 0000000..d14ed29 --- /dev/null +++ b/test/ShortUrls/Model/ShortUrlCreationTest.php @@ -0,0 +1,76 @@ +jsonSerialize()); + } + + public function provideConfigs(): iterable + { + $date = DateTimeImmutable::createFromFormat('Y-m-d', '2021-01-01'); + + yield [fn () => ShortUrlCreation::forLongUrl('https://foo.com'), ['longUrl' => 'https://foo.com']]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com')->returnExistingMatchingShortUrl(), + ['longUrl' => 'https://foo.com', 'findIfExists' => true], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com') + ->withTags('foo', 'bar') + ->validSince($date) + ->withCustomSlug('some-slug'), + [ + 'longUrl' => 'https://foo.com', + 'tags' => ['foo', 'bar'], + 'customSlug' => 'some-slug', + 'validSince' => $date->format(DateTimeInterface::ATOM), + ], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com') + ->withCustomSlug('some-slug') + ->withShortCodeLength(50), + ['longUrl' => 'https://foo.com', 'shortCodeLength' => 50], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com') + ->withShortCodeLength(50) + ->withCustomSlug('some-slug'), + ['longUrl' => 'https://foo.com', 'customSlug' => 'some-slug'], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com') + ->notValidatingTheLongUrl() + ->crawlable(), + ['longUrl' => 'https://foo.com', 'validateUrl' => false, 'crawlable' => true], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com')->validatingTheLongUrl(), + ['longUrl' => 'https://foo.com', 'validateUrl' => true], + ]; + yield [ + fn () => ShortUrlCreation::forLongUrl('https://foo.com') + ->withoutQueryForwardingOnRedirect() + ->forDomain('doma.in'), + ['longUrl' => 'https://foo.com', 'forwardQuery' => false, 'domain' => 'doma.in'], + ]; + } +} diff --git a/test/ShortUrls/Model/ShortUrlEditionTest.php b/test/ShortUrls/Model/ShortUrlEditionTest.php new file mode 100644 index 0000000..347644e --- /dev/null +++ b/test/ShortUrls/Model/ShortUrlEditionTest.php @@ -0,0 +1,69 @@ +jsonSerialize()); + } + + public function provideConfigs(): iterable + { + $date = DateTimeImmutable::createFromFormat('Y-m-d', '2021-01-01'); + + yield [fn () => ShortUrlEdition::create(), []]; + yield [ + fn () => ShortUrlEdition::create() + ->withTags('foo', 'bar') + ->validUntil($date) + ->withTitle('the title') + ->withMaxVisits(50), + [ + 'tags' => ['foo', 'bar'], + 'maxVisits' => 50, + 'validUntil' => $date->format(DateTimeInterface::ATOM), + 'title' => 'the title', + ], + ]; + yield [ + fn () => ShortUrlEdition::create() + ->withLongUrl('https://edited.com/foo/bar') + ->notValidatingTheLongUrl() + ->withoutTags(), + ['longUrl' => 'https://edited.com/foo/bar', 'validateUrl' => false, 'tags' => []], + ]; + yield [ + fn () => ShortUrlEdition::create() + ->removingValidUntil() + ->removingValidSince() + ->removingMaxVisits() + ->removingTitle() + ->notCrawlable() + ->withQueryForwardingOnRedirect(), + [ + 'maxVisits' => null, + 'validUntil' => null, + 'validSince' => null, + 'title' => null, + 'forwardQuery' => true, + 'crawlable' => false, + ], + ]; + } +} diff --git a/test/ShortUrls/Model/ShortUrlMetaTest.php b/test/ShortUrls/Model/ShortUrlMetaTest.php new file mode 100644 index 0000000..8683b75 --- /dev/null +++ b/test/ShortUrls/Model/ShortUrlMetaTest.php @@ -0,0 +1,43 @@ +validSince()); + self::assertEquals($expectedValidUntil, $meta->validUntil()); + self::assertEquals($expectedMaxVisits, $meta->maxVisits()); + } + + public function providePayloads(): iterable + { + $now = DateTimeImmutable::createFromFormat('Y-m-d', '2021-01-01'); + $formattedDate = $now->format(DateTimeInterface::ATOM); + + yield 'defaults' => [[], null, null, null]; + yield 'all data' => [[ + 'validSince' => $formattedDate, + 'validUntil' => $formattedDate, + 'maxVisits' => 35, + ], $now, $now, 35]; + } +} diff --git a/test/ShortUrls/Model/ShortUrlTest.php b/test/ShortUrls/Model/ShortUrlTest.php new file mode 100644 index 0000000..b056dfb --- /dev/null +++ b/test/ShortUrls/Model/ShortUrlTest.php @@ -0,0 +1,96 @@ +shortCode()); + self::assertEquals($expectedShortUrl, $shortUrl->shortUrl()); + self::assertEquals($expectedLongUrl, $shortUrl->longUrl()); + self::assertEquals($expectedDateCreated, $shortUrl->dateCreated()); + self::assertEquals($expectedVisitsCount, $shortUrl->visitsCount()); + self::assertEquals($expectedDomain, $shortUrl->domain()); + self::assertEquals($expectedTitle, $shortUrl->title()); + self::assertEquals($expectedCrawlable, $shortUrl->crawlable()); + self::assertEquals($expectedForwardQuery, $shortUrl->forwardQuery()); + self::assertEquals($expectedTags, $shortUrl->tags()); + self::assertEquals($expectedMeta, $shortUrl->meta()); + } + + public function providePayloads(): iterable + { + $now = DateTimeImmutable::createFromFormat('Y-m-d', '2021-01-01'); + $formattedDate = $now->format(DateTimeInterface::ATOM); + + yield 'defaults' => [ + ['dateCreated' => $formattedDate], + '', + '', + '', + $now, + 0, + null, + null, + false, + false, + [], + ShortUrlMeta::fromArray([]), + ]; + yield 'all values' => [ + [ + 'shortCode' => 'foo', + 'shortUrl' => 'https://doma.in/foo', + 'longUrl' => 'https://foo.com/bar', + 'dateCreated' => $formattedDate, + 'visitsCount' => 35, + 'domain' => 'domain', + 'title' => 'title', + 'crawlable' => true, + 'forwardQuery' => true, + 'tags' => ['foo', 'bar'], + 'meta' => $meta = [ + 'maxVisits' => 30, + ], + ], + 'foo', + 'https://doma.in/foo', + 'https://foo.com/bar', + $now, + 35, + 'domain', + 'title', + true, + true, + ['foo', 'bar'], + ShortUrlMeta::fromArray($meta), + ]; + } +} diff --git a/test/ShortUrls/Model/ShortUrlsFilterTest.php b/test/ShortUrls/Model/ShortUrlsFilterTest.php new file mode 100644 index 0000000..6391fab --- /dev/null +++ b/test/ShortUrls/Model/ShortUrlsFilterTest.php @@ -0,0 +1,53 @@ +toArray()); + } + + public function providePayloads(): iterable + { + $date = new DateTimeImmutable(); + + yield [fn () => ShortUrlsFilter::create(), []]; + yield [ + fn () => ShortUrlsFilter::create() + ->since($date) + ->until($date), + ['startDate' => $formatted = $date->format(DateTimeInterface::ATOM), 'endDate' => $formatted], + ]; + yield [ + fn () => ShortUrlsFilter::create() + ->containingTags('foo', 'bar') + ->searchingBy('searching'), + ['tags' => ['foo', 'bar'], 'searchTerm' => 'searching'], + ]; + yield [ + fn () => ShortUrlsFilter::create()->orderingAscBy(ShortUrlListOrderFields::VISITS), + ['orderBy' => 'visits-ASC'], + ]; + yield [ + fn () => ShortUrlsFilter::create()->orderingDescBy(ShortUrlListOrderFields::LONG_URL), + ['orderBy' => 'longUrl-DESC'], + ]; + } +} diff --git a/test/ShortUrls/ShortUrlsClientTest.php b/test/ShortUrls/ShortUrlsClientTest.php new file mode 100644 index 0000000..baf6de7 --- /dev/null +++ b/test/ShortUrls/ShortUrlsClientTest.php @@ -0,0 +1,284 @@ +httpClient = $this->prophesize(HttpClientInterface::class); + $this->client = new ShortUrlsClient($this->httpClient->reveal()); + $this->now = (new DateTimeImmutable())->format(DateTimeInterface::ATOM); + ; + } + + /** @test */ + public function listShortUrlVisitsPerformsExpectedCall(): void + { + $amountOfPages = 3; + $now = $this->now; + + $get = $this->httpClient->getFromShlink('/short-urls', Argument::any())->will( + function (array $args) use ($amountOfPages, $now) { + $page = $args[1]['page']; + $data = [ + [ + 'shortCode' => 'shortCode_' . $page . '_1', + 'longUrl' => 'longUrl_' . $page . '_1', + 'dateCreated' => $now, + ], + [ + 'shortCode' => 'shortCode_' . $page . '_2', + 'longUrl' => 'longUrl_' . $page . '_2', + 'dateCreated' => $now, + ], + ]; + + return [ + 'shortUrls' => [ + 'data' => $data, + 'pagination' => [ + 'currentPage' => $page, + 'pagesCount' => $amountOfPages, + 'totalItems' => $amountOfPages * count($data), + ], + ], + ]; + }, + ); + + $result = $this->client->listShortUrls(); + + self::assertCount($amountOfPages * 2, $result); + + $count = 0; + foreach ($result as $index => $shortUrl) { + $count++; + self::assertStringStartsWith('shortCode_', $shortUrl->shortCode()); + self::assertStringStartsWith('longUrl_', $shortUrl->longUrl()); + self::assertStringEndsWith($index % 2 === 0 ? '_1' : '_2', $shortUrl->shortCode()); + self::assertStringEndsWith($index % 2 === 0 ? '_1' : '_2', $shortUrl->longUrl()); + self::assertStringStartsWith($shortUrl->dateCreated()->format('Y-m-d'), $now); + } + + self::assertEquals($amountOfPages * 2, $count); + $get->shouldHaveBeenCalledTimes($amountOfPages); + } + + /** + * @test + * @dataProvider provideIdentifiers + */ + public function getShortUrlPerformsExpectedCall(ShortUrlIdentifier $identifier): void + { + $expected = ['dateCreated' => $this->now]; + $get = $this->httpClient->getFromShlink(sprintf('/short-urls/%s', $identifier->shortCode()), Argument::that( + fn (array $query): bool => $query['domain'] === $identifier->domain(), + ))->willReturn($expected); + + $result = $this->client->getShortUrl($identifier); + + self::assertEquals(ShortUrl::fromArray($expected), $result); + $get->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideIdentifiers + */ + public function deleteShortUrlPerformsExpectedCall(ShortUrlIdentifier $identifier): void + { + $call = $this->httpClient->callShlinkWithBody( + sprintf('/short-urls/%s', $identifier->shortCode()), + 'DELETE', + [], + Argument::that(fn (array $query): bool => $query['domain'] === $identifier->domain()), + ); + + $this->client->deleteShortUrl($identifier); + + $call->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideIdentifiers + */ + public function editShortUrlPerformsExpectedCall(ShortUrlIdentifier $identifier): void + { + $expected = ['dateCreated' => $this->now]; + $edit = ShortUrlEdition::create(); + $call = $this->httpClient->callShlinkWithBody( + sprintf('/short-urls/%s', $identifier->shortCode()), + 'PATCH', + $edit, + Argument::that(fn (array $query): bool => $query['domain'] === $identifier->domain()), + )->willReturn($expected); + + $result = $this->client->editShortUrl($identifier, $edit); + + self::assertEquals(ShortUrl::fromArray($expected), $result); + $call->shouldHaveBeenCalledOnce(); + } + + public function provideIdentifiers(): iterable + { + yield 'no domain' => [ShortUrlIdentifier::fromShortCode('foo')]; + yield 'domain' => [ShortUrlIdentifier::fromShortCodeAndDomain('foo', 'doma.in')]; + } + + /** @test */ + public function createShortUrlPerformsExpectedCall(): void + { + $expected = ['dateCreated' => $this->now]; + $create = ShortUrlCreation::forLongUrl('https://foo.com'); + $call = $this->httpClient->callShlinkWithBody('/short-urls', 'POST', $create)->willReturn($expected); + + $result = $this->client->createShortUrl($create); + + self::assertEquals(ShortUrl::fromArray($expected), $result); + $call->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideGetExceptions + */ + public function getShortUrlThrowsProperExceptionOnError(HttpException $original, string $expected): void + { + $get = $this->httpClient->getFromShlink(Argument::cetera())->willThrow($original); + + $get->shouldBeCalledOnce(); + $this->expectException($expected); + + $this->client->getShortUrl(ShortUrlIdentifier::fromShortCode('foo')); + } + + public function provideGetExceptions(): iterable + { + yield 'no type' => [HttpException::fromPayload([]), HttpException::class]; + yield 'not expected type' => [HttpException::fromPayload(['type' => 'something else']), HttpException::class]; + yield 'INVALID_SHORTCODE type' => [ + HttpException::fromPayload(['type' => 'INVALID_SHORTCODE']), + ShortUrlNotFoundException::class, + ]; + } + + /** + * @test + * @dataProvider provideDeleteExceptions + */ + public function deleteShortUrlThrowsProperExceptionOnError(HttpException $original, string $expected): void + { + $call = $this->httpClient->callShlinkWithBody(Argument::cetera())->willThrow($original); + + $call->shouldBeCalledOnce(); + $this->expectException($expected); + + $this->client->deleteShortUrl(ShortUrlIdentifier::fromShortCode('foo')); + } + + public function provideDeleteExceptions(): iterable + { + yield 'no type' => [HttpException::fromPayload([]), HttpException::class]; + yield 'not expected type' => [HttpException::fromPayload(['type' => 'something else']), HttpException::class]; + yield 'INVALID_SHORTCODE type' => [ + HttpException::fromPayload(['type' => 'INVALID_SHORTCODE']), + ShortUrlNotFoundException::class, + ]; + yield 'INVALID_SHORTCODE_DELETION type' => [ + HttpException::fromPayload(['type' => 'INVALID_SHORTCODE_DELETION']), + DeleteShortUrlThresholdException::class, + ]; + } + + /** + * @test + * @dataProvider provideCreateExceptions + */ + public function createShortUrlThrowsProperExceptionOnError(HttpException $original, string $expected): void + { + $call = $this->httpClient->callShlinkWithBody(Argument::cetera())->willThrow($original); + + $call->shouldBeCalledOnce(); + $this->expectException($expected); + + $this->client->createShortUrl(ShortUrlCreation::forLongUrl('https://foof.com')); + } + + public function provideCreateExceptions(): iterable + { + yield 'no type' => [HttpException::fromPayload([]), HttpException::class]; + yield 'not expected type' => [HttpException::fromPayload(['type' => 'something else']), HttpException::class]; + yield 'INVALID_ARGUMENT type' => [ + HttpException::fromPayload(['type' => 'INVALID_ARGUMENT']), + InvalidDataException::class, + ]; + yield 'INVALID_URL type' => [ + HttpException::fromPayload(['type' => 'INVALID_URL']), + InvalidLongUrlException::class, + ]; + yield 'INVALID_SLUG type' => [ + HttpException::fromPayload(['type' => 'INVALID_SLUG']), + NonUniqueSlugException::class, + ]; + } + + /** + * @test + * @dataProvider provideEditExceptions + */ + public function editShortUrlThrowsProperExceptionOnError(HttpException $original, string $expected): void + { + $call = $this->httpClient->callShlinkWithBody(Argument::cetera())->willThrow($original); + + $call->shouldBeCalledOnce(); + $this->expectException($expected); + + $this->client->editShortUrl(ShortUrlIdentifier::fromShortCode('foo'), ShortUrlEdition::create()); + } + + public function provideEditExceptions(): iterable + { + yield 'no type' => [HttpException::fromPayload([]), HttpException::class]; + yield 'not expected type' => [HttpException::fromPayload(['type' => 'something else']), HttpException::class]; + yield 'INVALID_SHORTCODE type' => [ + HttpException::fromPayload(['type' => 'INVALID_SHORTCODE']), + ShortUrlNotFoundException::class, + ]; + yield 'INVALID_ARGUMENT type' => [ + HttpException::fromPayload(['type' => 'INVALID_ARGUMENT']), + InvalidDataException::class, + ]; + } +}