diff --git a/README.md b/README.md index 6fbaefa..bfcae97 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # API TDD +## Sobre -## Desafio +Projeto para fins educacionais e de aprendizado. Desenvolvido em formato Live no canal 🐧 **Pinguim do Laravel**. + +đŸ“ș Acompanhe: [\#16 - Laravel | TDD](https://www.youtube.com/watch?v=-WUq9QilQVU) + +## TO DO + +### Desafio - [ ] Garantir que o cĂłdigo da url curta nĂŁo se repita ---- - - [X] Salvar um endpoint - [X] Precisar enviar o endpoint que queremos encurtar - [X] Endpoint tem que ser vĂĄlido @@ -42,9 +48,4 @@ ], "total": 40 } -``` - - - - - +``` \ No newline at end of file diff --git a/app/Actions/CodeGenerator.php b/app/Actions/CodeGenerator.php deleted file mode 100644 index 5f12595..0000000 --- a/app/Actions/CodeGenerator.php +++ /dev/null @@ -1,13 +0,0 @@ -random(5); + + if (ShortUrl::where('code', $code)->exists()) { + return $this->generate(); + } + + return $code; + } +} diff --git a/app/Facades/Actions/CodeGenerator.php b/app/Facades/Actions/UrlCode.php similarity index 69% rename from app/Facades/Actions/CodeGenerator.php rename to app/Facades/Actions/UrlCode.php index 5e48ab6..e2c4765 100644 --- a/app/Facades/Actions/CodeGenerator.php +++ b/app/Facades/Actions/UrlCode.php @@ -7,10 +7,10 @@ /** * @method static string run() */ -class CodeGenerator extends Facade +class UrlCode extends Facade { protected static function getFacadeAccessor(): string { - return \App\Actions\CodeGenerator::class; + return \App\Actions\UrlCode::class; } } diff --git a/app/Http/Controllers/ShortUrlController.php b/app/Http/Controllers/ShortUrlController.php index 838b89b..1293420 100644 --- a/app/Http/Controllers/ShortUrlController.php +++ b/app/Http/Controllers/ShortUrlController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Facades\Actions\CodeGenerator; +use App\Facades\Actions\UrlCode; use App\Models\ShortUrl; use Symfony\Component\HttpFoundation\Response; @@ -14,7 +14,7 @@ public function store() 'url' => 'required|url', ]); - $code = CodeGenerator::run(); + $code = UrlCode::generate(); $shortUrl = ShortUrl::query() ->firstOrCreate([ diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php index 62cbf4e..d98de9e 100644 --- a/app/Http/Controllers/StatsController.php +++ b/app/Http/Controllers/StatsController.php @@ -15,6 +15,7 @@ public function lastVisit(ShortUrl $shortUrl) public function visits(ShortUrl $shortUrl) { + // Does not work in SQLite $visits = $shortUrl->visits() ->selectRaw(" @@ -26,7 +27,6 @@ public function visits(ShortUrl $shortUrl) ray($visits->toArray()); - return [ 'total' => $shortUrl->visits()->count(), 'visits' => $visits->toArray(), diff --git a/composer.json b/composer.json index 18f230a..ed68714 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ } }, "scripts": { + "pestify": "./vendor/bin/pest tests/Feature/Pest/", "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/database/factories/ShortUrlFactory.php b/database/factories/ShortUrlFactory.php index 75e2032..555dc3c 100644 --- a/database/factories/ShortUrlFactory.php +++ b/database/factories/ShortUrlFactory.php @@ -14,7 +14,7 @@ public function definition(): array return [ 'url' => $this->faker->url(), 'short_url' => $this->faker->url(), - 'code' => $this->faker->word(), + 'code' => $this->faker->word(5), ]; } } diff --git a/phpunit.xml b/phpunit.xml index 1f0c395..8dbb996 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,10 +19,12 @@ + - - + + + diff --git a/tests/Feature/Pest/Actions/UrlCodeTest.php b/tests/Feature/Pest/Actions/UrlCodeTest.php new file mode 100644 index 0000000..27e044b --- /dev/null +++ b/tests/Feature/Pest/Actions/UrlCodeTest.php @@ -0,0 +1,27 @@ +expect(fn () => UrlCode::generate()) + ->toBeString() + ->toHaveLength(5); + +test('codes cannot be repeated', function () { + $code = '12345'; + + shortUrl()->create(['code' => $code]); + + expect(ShortUrl::all()) + ->count()->toBe(1) + ->first() + ->code->toBe($code); + + $newCode = UrlCode::generate($code); + + expect($newCode) + ->toBeString() + ->toHaveLength(5) + ->not->toBe($code); +}); diff --git a/tests/Feature/Pest/ShortUrl/CreateTest.php b/tests/Feature/Pest/ShortUrl/CreateTest.php index 88d1077..c24ba37 100644 --- a/tests/Feature/Pest/ShortUrl/CreateTest.php +++ b/tests/Feature/Pest/ShortUrl/CreateTest.php @@ -1,58 +1,66 @@ once() ->andReturn($randomCode); - postJson( - route('api.short-url.store'), - ['url' => 'https://www.google.com'] - ) - ->assertStatus(Response::HTTP_CREATED) - ->assertJson([ - 'short_url' => config('app.url') . '/' . $randomCode, - ]); + $testUrl = config('app.url') . '/' . $randomCode; - $this->assertDatabaseHas('short_urls', [ - 'url' => 'https://www.google.com', - 'short_url' => config('app.url') . '/' . $randomCode, - 'code' => $randomCode, - ]); + $response = storeShortUrl(['url' => 'https://www.google.com']); + + expect($response) + ->status()->toBeCreated() + ->content() + ->json() + ->toMatchArray(['short_url' => $testUrl]); + + expect(ShortUrl::count())->toBe(1); + + expect(ShortUrl::first()) + ->url->ToBe('https://www.google.com') + ->code->toBe($randomCode) + ->short_url->toBe($testUrl); }); -test('url should be a valid url', function () { - $this->postJson( - route('api.short-url.store'), - ['url' => 'not-valid-url'] - )->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) - ->assertJsonValidationErrors([ - 'url' => __('validation.url', ['attribute' => 'url']), +it('rejects an invalid URL', function () { + $response = storeShortUrl(['url' => 'not-valid-url']); + + expect($response) + ->status()->toBeUnprocessableEntity() + ->content() + ->json() + ->toMatchArray([ + "message" => "The url must be a valid URL.", + "errors" => [ + "url" => ["The url must be a valid URL."] + ] ]); }); -it('should return the existed code if the url is the same', function () { - ShortUrl::factory()->create([ +it('returns the existing code when the URL already exists', function () { + $code = '123456'; + $shortUrl = config('app.url') . '/' . $code; + + shortUrl()->create([ 'url' => 'https://www.google.com', - 'short_url' => config('app.url') . '/123456', - 'code' => '123456', + 'short_url' => $shortUrl, + 'code' => $code ]); - postJson( - route('api.short-url.store'), - ['url' => 'https://www.google.com'] - )->assertJson([ - 'short_url' => config('app.url') . '/123456', - ]); + $response = storeShortUrl(['url' => 'https://www.google.com']); - $this->assertDatabaseCount('short_urls', 1); -}); + expect($response) + ->status()->toBeCreated() + ->content() + ->json() + ->toMatchArray(['short_url' => $shortUrl]); + expect(ShortUrl::count())->toBe(1); +}); diff --git a/tests/Feature/Pest/ShortUrl/DeleteTest.php b/tests/Feature/Pest/ShortUrl/DeleteTest.php index 4698a82..cf319f8 100644 --- a/tests/Feature/Pest/ShortUrl/DeleteTest.php +++ b/tests/Feature/Pest/ShortUrl/DeleteTest.php @@ -1,15 +1,22 @@ create(); - $this->deleteJson(route('api.short-url.destroy', $shortUrl->code)) - ->assertStatus(Response::HTTP_NO_CONTENT); +it('can delete a short url', function () { + $shortUrl = shortUrl()->create(); - $this->assertDatabaseMissing('short_urls', [ - 'id' => $shortUrl->id, - ]); + $response = deleteJson(route('api.short-url.destroy', $shortUrl->code)); + + expect($response) + ->status() + ->toBeNoContent(); }); +it('can delete a short url [HIGH ORDER]') + ->tap(fn () => $this->code = shortUrl()->create()->code) + ->tap(fn () => $this->response = deleteJson(route('api.short-url.destroy', $this->code))) + ->expect(fn () => $this->response) + ->status()->toBeNoContent() + ->expect(fn () => $this->count = ShortUrl::count()) + ->toBe(0); diff --git a/tests/Feature/Pest/ShortUrl/MiscTest.php b/tests/Feature/Pest/ShortUrl/MiscTest.php new file mode 100644 index 0000000..7ac18a0 --- /dev/null +++ b/tests/Feature/Pest/ShortUrl/MiscTest.php @@ -0,0 +1,13 @@ +get('/') + ->assertOk(); + +it('is incomplete'); + +it('will not run')->skip(); + +test('only this test will run! (//comment me out)') + ->expect(true)->toBeTrue() + ->only(); diff --git a/tests/Feature/Pest/ShortUrl/StatsTest.php b/tests/Feature/Pest/ShortUrl/StatsTest.php index e98d367..de500e2 100644 --- a/tests/Feature/Pest/ShortUrl/StatsTest.php +++ b/tests/Feature/Pest/ShortUrl/StatsTest.php @@ -1,49 +1,41 @@ createOne(); + $shortUrl = shortUrl()->create(); get($shortUrl->code); - getJson(route('api.short-url.stats.last-visit', $shortUrl->code)) - ->assertSuccessful() - ->assertJson([ - 'last_visit' => $shortUrl->last_visit?->toIso8601String(), - ]); + $response = getLastVisit($shortUrl->code); + + expect($response) + ->status()->toBe(Response::HTTP_OK) + ->content() + ->json() + ->toMatchArray(['last_visit' => $shortUrl->last_visit?->toIso8601String()]); - $this->assertDatabaseHas('visits', [ + assertDatabaseHas('visits', [ 'short_url_id' => $shortUrl->id, 'created_at' => Carbon::now(), ]); -}); - +})->requiresMysql(); it('should return the amount per day of visits with a total', function () { + $shortUrl = shortUrl()->create(); - $shortUrl = ShortUrl::factory()->createOne(); + createVisits($shortUrl); - Visit::factory() - ->count(12) - ->state(new Sequence( - ['created_at' => Carbon::now()->subDays(3)], - ['created_at' => Carbon::now()->subDays(2)], - ['created_at' => Carbon::now()->subDay()], - ['created_at' => Carbon::now()] - )) - ->create([ - 'short_url_id' => $shortUrl->id, - ]); + $response = getStats($shortUrl->code); - getJson(route('api.short-url.stats.visits', $shortUrl->code)) - ->assertSuccessful() - ->assertJson([ + expect($response) + ->status()->toBe(Response::HTTP_OK) + ->content() + ->json() + ->toMatchArray([ 'total' => 12, 'visits' => [ [ @@ -64,4 +56,4 @@ ], ], ]); -}); +})->requiresMysql(); diff --git a/tests/Feature/PestShortUrlCreateTest.php b/tests/Feature/PestShortUrlCreateTest.php deleted file mode 100644 index c282e33..0000000 --- a/tests/Feature/PestShortUrlCreateTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/pestshorturlcreate'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/ShortUrl/CreateTest.php b/tests/Feature/ShortUrl/CreateTest.php index 8b007a6..0f3b69c 100644 --- a/tests/Feature/ShortUrl/CreateTest.php +++ b/tests/Feature/ShortUrl/CreateTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature\ShortUrl; -use App\Facades\Actions\CodeGenerator; +use App\Facades\Actions\UrlCode; use App\Models\ShortUrl; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -15,7 +15,7 @@ public function it_should_be_able_to_create_a_short_url() { $randomCode = Str::random(5); - CodeGenerator::shouldReceive('run') + UrlCode::shouldReceive('generate') ->once() ->andReturn($randomCode); diff --git a/tests/Pest.php b/tests/Pest.php index 681e747..6cefeb3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,15 @@ extend('toBeCreated', function () { + return $this->ToBe(Response::HTTP_CREATED); +}); -expect()->extend('toBeOne', function () { - return $this->toBe(1); +expect()->extend('toBeNoContent', function () { + return $this->ToBe(Response::HTTP_NO_CONTENT); }); +expect()->extend('toBeUnprocessableEntity', function () { + return $this->ToBe(Response::HTTP_UNPROCESSABLE_ENTITY); +}); /* |-------------------------------------------------------------------------- | Functions @@ -40,7 +56,73 @@ | */ -function something() +/** + * ShortUrl Factory + * + */ +function shortUrl(): ShortUrlFactory +{ + return ShortUrlFactory::new(); +} + +/** + * Create (store) a shortUrl + * + * @param array $data + * @return \Illuminate\Testing\TestResponse + */ +function storeShortUrl(array $data = []) { - // .. + return postJson(route('api.short-url.store'), $data); +} + +/** + * Access a ShortURL Stats + * + * @param string $code + * @return \Illuminate\Testing\TestResponse + */ +function getStats(string $code) +{ + return getJson(route('api.short-url.stats.visits', $code)); +} + +/** + * Access a ShortURL Stats + * + * @param string $code + * @return \Illuminate\Testing\TestResponse + */ +function getLastVisit(string $code) +{ + return getJson(route('api.short-url.stats.last-visit', $code)); +} + +/** + * Generates Visits with VisitFactory + * + * @param ShortUrl $shortUrl + */ +function createVisits(ShortUrl $shortUrl): void +{ + Visit::factory() + ->count(12) + ->state(new Sequence( + ['created_at' => Carbon::now()->subDays(3)], + ['created_at' => Carbon::now()->subDays(2)], + ['created_at' => Carbon::now()->subDay()], + ['created_at' => Carbon::now()] + )) + ->create([ + 'short_url_id' => $shortUrl->id, + ]); +} + +function requiresMysql() +{ + if (DB::getDriverName() !== 'mysql') { + test()->markTestSkipped('This test requires MySQL database'); + } + + return test(); } diff --git a/todo.md b/todo.md deleted file mode 100644 index 4800962..0000000 --- a/todo.md +++ /dev/null @@ -1,42 +0,0 @@ -# API TDD - -- [X] Salvar um endpoint - - [X] Precisar enviar o endpoint que queremos encurtar - - [X] Endpoint tem que ser vĂĄlido - - [X] nĂŁo pode se repetir - - [X] Esperamos receber uma url encurtada pdl.test/YH21 - - [X] Esperamos receber um status code 201 -- [X] Deletar a url curta baseado na url gerada - - [X] url precisa existir - - [X] receber um 204[no content] caso deletado com sucesso -- [X] Pegar estatistica de uso da url /stats/YH21 - - [X] ultima vez que foi utilizada - -```json -{ - "last_visit": "2022-02-17T13:45:00" -} -``` - - - [X] Receber quantas vezes a url foi usada - -```json -{ - "visits": [ - { - "day": "2022-02-16", - "qty": 20 - }, - { - "day": "2022-02-17", - "qty": 20 - } - ], - "total": 40 -} -``` - - - - -