diff --git a/.env.ci b/.env.ci index e1b49aa..01ec03a 100755 --- a/.env.ci +++ b/.env.ci @@ -12,8 +12,7 @@ APP_MAINTENANCE_DRIVER=file PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=4 -LOG_CHANNEL=stack -LOG_STACK=single +LOG_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 402351c..88a4146 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: write + jobs: lint: runs-on: ubuntu-latest @@ -59,4 +62,4 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v6 with: commit_message: "chore: fix code style" - commit_options: '--no-verify' + commit_options: "--no-verify" diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e634ec7..98dd452 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Settings\SeoSettings; use App\Settings\SiteSettings; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; use Packages\React\Services\ReactService; @@ -37,11 +38,13 @@ public function boot(): void { Vite::prefetch(concurrency: 6); - register_shutdown_function(function () { - if (memory_get_usage() > 100 * 1024 * 1024) { - logger()->warning('High memory usage: ' . memory_get_usage()); - } - }); + if ($this->app->environment('testings')) { + register_shutdown_function(function () { + if (memory_get_usage() > 100 * 1024 * 1024) { + Log::warning('High memory usage: ' . memory_get_usage()); + } + }); + } $this->loadObservers(); $this->loadMacros(); diff --git a/composer.json b/composer.json index 097b7fb..9d5ce57 100755 --- a/composer.json +++ b/composer.json @@ -92,7 +92,8 @@ "psr-4": { "Tests\\": "tests/", "Packages\\Tag\\Tests\\": "packages/Tag/tests/", - "Packages\\Category\\Tests\\": "packages/Category/tests/" + "Packages\\Category\\Tests\\": "packages/Category/tests/", + "Packages\\Article\\Tests\\": "packages/Article/tests/" } }, "scripts": { diff --git a/composer.lock b/composer.lock index 703b687..aa7e130 100755 --- a/composer.lock +++ b/composer.lock @@ -936,16 +936,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.356.12", + "version": "3.369.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "0068088fed44e2bed3a2e82f2c7a0bb9e356b3c3" + "reference": "2bbe45aaaaa23a863a5daadcda326cf1c8b4a15b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0068088fed44e2bed3a2e82f2c7a0bb9e356b3c3", - "reference": "0068088fed44e2bed3a2e82f2c7a0bb9e356b3c3", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2bbe45aaaaa23a863a5daadcda326cf1c8b4a15b", + "reference": "2bbe45aaaaa23a863a5daadcda326cf1c8b4a15b", "shasum": "" }, "require": { @@ -958,7 +958,8 @@ "guzzlehttp/psr7": "^2.4.5", "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", - "psr/http-message": "^2.0" + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -969,13 +970,11 @@ "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", - "ext-pcntl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "phpunit/phpunit": "^9.6", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", - "symfony/filesystem": "^v6.4.0 || ^v7.1.0", "yoast/phpunit-polyfills": "^2.0" }, "suggest": { @@ -983,6 +982,7 @@ "doctrine/cache": "To use the DoctrineCacheAdapter", "ext-curl": "To send requests using cURL", "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", "ext-sockets": "To use client-side monitoring" }, "type": "library", @@ -1027,9 +1027,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.356.12" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.0" }, - "time": "2025-09-05T18:10:41+00:00" + "time": "2025-12-19T19:08:40+00:00" }, { "name": "bacon/bacon-qr-code", @@ -9930,6 +9930,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:47+00:00" + }, { "name": "symfony/finder", "version": "v7.3.2", @@ -17012,76 +17082,6 @@ ], "time": "2025-03-13T15:25:07+00:00" }, - { - "name": "symfony/filesystem", - "version": "v7.3.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-07T08:17:47+00:00" - }, { "name": "symfony/http-client", "version": "v7.3.4", @@ -17534,5 +17534,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/packages/Article/database/factories/ArticleFactory.php b/packages/Article/database/factories/ArticleFactory.php index 788371b..b474df5 100755 --- a/packages/Article/database/factories/ArticleFactory.php +++ b/packages/Article/database/factories/ArticleFactory.php @@ -3,6 +3,7 @@ namespace Packages\Article\Database\Factories; use App\Models\User; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; use Packages\Article\Models\Article; @@ -25,14 +26,16 @@ public function definition() return [ 'title' => $title, - 'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1, 9999), + 'slug' => Str::slug($title), 'excerpt' => $this->faker->paragraph(2), 'content' => $this->faker->paragraphs(5, true), 'status' => $this->faker->randomElement(['DRAFT', 'PUBLISHED', 'PENDING']), 'sources' => collect(range(0, rand(0, 5)))->map(function () { return [ - 'url' => $this->faker->domainName(), - 'date' => $this->faker->dateTimeBetween('-6 months', '+6 months'), + 'url' => $this->faker->url(), + 'date' => Carbon::instance( + $this->faker->dateTimeBetween('-6 months', '+6 months') + )->toDateTimeString(), ]; })->toArray(), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'), diff --git a/packages/Article/database/seeders/ArticleSeeder.php b/packages/Article/database/seeders/ArticleSeeder.php index c322f3a..3adecb0 100755 --- a/packages/Article/database/seeders/ArticleSeeder.php +++ b/packages/Article/database/seeders/ArticleSeeder.php @@ -4,20 +4,11 @@ use Illuminate\Database\Seeder; use Packages\Article\Models\Article; -use Packages\React\Models\Comment; -use Packages\React\Models\Dislike; -use Packages\React\Models\Like; -use Packages\React\Models\Save; class ArticleSeeder extends Seeder { public function run(): void { - Article::factory(20)->create()->each(function (Article $article) { - Like::factory(10)->forModel($article)->create(); - Dislike::factory(10)->forModel($article)->create(); - Save::factory(10)->forModel($article)->create(); - Comment::factory(20)->forModel($article)->create(); - }); + Article::factory(20)->create(); } } diff --git a/packages/Article/routes/web.php b/packages/Article/routes/web.php index e4a1d5c..c8adf51 100644 --- a/packages/Article/routes/web.php +++ b/packages/Article/routes/web.php @@ -9,7 +9,7 @@ Route::middleware([HandleInertiaRequests::class])->prefix('makale')->name('article.')->group(function () { Route::controller(ArticleCrudController::class)->middleware('auth')->name('crud.')->group(function () { Route::get('/yaz', 'createView')->name('create.view')->can('create', Article::class); - Route::get('/{article:slug}/duzenle', 'editView')->name('edit.view')->can('view,article'); + Route::get('/{article:slug}/duzenle', 'editView')->name('edit.view')->can('update,article'); }); Route::controller(ArticleController::class)->group(function () { diff --git a/packages/Article/src/Data/ArticleData.php b/packages/Article/src/Data/ArticleData.php index 2c0b686..57465fc 100755 --- a/packages/Article/src/Data/ArticleData.php +++ b/packages/Article/src/Data/ArticleData.php @@ -17,7 +17,7 @@ class ArticleData extends Data * @param array{image: string, responsive: string|array, srcset: string, thumb: string|null} $image * @param array $categories * @param array $tags - * @param array{url: string, date: string} $sources + * @param array $sources */ public function __construct( public ?int $id, diff --git a/packages/Article/src/Http/Requests/CreateRequest.php b/packages/Article/src/Http/Requests/CreateRequest.php index 87555f2..319d1e3 100644 --- a/packages/Article/src/Http/Requests/CreateRequest.php +++ b/packages/Article/src/Http/Requests/CreateRequest.php @@ -31,16 +31,16 @@ public function rules(): array if (! $isDraft) { $rules = array_merge($rules, [ - 'excerpt' => ['required', 'string', 'min:100'], - 'content' => ['required', 'string', 'min:500'], - 'categories' => ['required', 'array', 'min:1', 'max:3'], - 'categories.*' => ['string'], - 'tags' => ['required', 'array', 'min:1', 'max:3'], - 'tags.*' => ['string'], - 'image' => ['required', 'file', 'image'], - 'sources' => ['required', 'array', 'min:1'], - 'sources.url' => ['required', 'string', 'url'], - 'sources.date' => ['required', 'date'], + 'excerpt' => ['required', 'string', 'min:100'], + 'content' => ['required', 'string', 'min:500'], + 'categories' => ['required', 'array', 'min:1', 'max:3'], + 'categories.*' => ['string'], + 'tags' => ['required', 'array', 'min:1', 'max:3'], + 'tags.*' => ['string'], + 'image' => ['required', 'file', 'image'], + 'sources' => ['required', 'array', 'min:1'], + 'sources.*.url' => ['required', 'string', 'url'], + 'sources.*.date' => ['required', 'date'], ]); } diff --git a/packages/Article/src/Http/Requests/EditRequest.php b/packages/Article/src/Http/Requests/EditRequest.php index 338c5b9..1d5e255 100644 --- a/packages/Article/src/Http/Requests/EditRequest.php +++ b/packages/Article/src/Http/Requests/EditRequest.php @@ -31,16 +31,16 @@ public function rules(): array if (! $isDraft) { $rules = array_merge($rules, [ - 'excerpt' => ['required', 'string', 'min:100'], - 'content' => ['required', 'string', 'min:500'], - 'categories' => ['required', 'array', 'min:1', 'max:3'], - 'categories.*' => ['string'], - 'tags' => ['required', 'array', 'min:1', 'max:3'], - 'tags.*' => ['string'], - 'image' => ['required', 'file', 'image'], - 'sources' => ['required', 'array', 'min:1'], - 'sources.url' => ['required', 'string', 'url'], - 'sources.date' => ['required', 'date'], + 'excerpt' => ['required', 'string', 'min:100'], + 'content' => ['required', 'string', 'min:500'], + 'categories' => ['required', 'array', 'min:1', 'max:3'], + 'categories.*' => ['string'], + 'tags' => ['required', 'array', 'min:1', 'max:3'], + 'tags.*' => ['string'], + 'image' => ['required', 'file', 'image'], + 'sources' => ['required', 'array', 'min:1'], + 'sources.*.url' => ['required', 'string', 'url'], + 'sources.*.date' => ['required', 'date'], ]); } diff --git a/packages/Article/tests/Feature/Console/Commands/ScheduleArticleCommandTest.php b/packages/Article/tests/Feature/Console/Commands/ScheduleArticleCommandTest.php new file mode 100644 index 0000000..d6df9d4 --- /dev/null +++ b/packages/Article/tests/Feature/Console/Commands/ScheduleArticleCommandTest.php @@ -0,0 +1,109 @@ +travelTo(now()); + } + + public function test_it_publishes_valid_pending_articles(): void + { + Event::fake(); + + $articleToPublish = Article::factory()->create([ + 'status' => 'PENDING', + 'published_at' => now()->subMinute(), + ]); + + $articleFuture = Article::factory()->create([ + 'status' => 'PENDING', + 'published_at' => now()->addDay(), + ]); + + $articleAlreadyPublished = Article::factory()->create([ + 'status' => 'PUBLISHED', + 'published_at' => now()->subMinute(), + ]); + + $this->artisan('article:schedule') + ->expectsOutput('Pending articles checked') + ->assertExitCode(0); + + $this->assertEquals('PUBLISHED', $articleToPublish->refresh()->status); + $this->assertEquals('PENDING', $articleFuture->refresh()->status); + $this->assertEquals('PUBLISHED', $articleAlreadyPublished->refresh()->status); + + Event::assertDispatched(ArticlePublishedEvent::class, function ($event) use ($articleToPublish) { + return $event->article->id === $articleToPublish->id; + }); + + Event::assertDispatchedTimes(ArticlePublishedEvent::class, 1); + + Event::assertNotDispatched(ArticlePublishedEvent::class, function ($event) use ($articleFuture) { + return $event->article->id === $articleFuture->id; + }); + + $this->assertDatabaseHas('articles', [ + 'id' => $articleToPublish->id, + 'status' => 'PUBLISHED', + ]); + } + + public function test_it_does_not_publish_articles_without_publish_date(): void + { + Event::fake(); + + $article = Article::factory()->create([ + 'status' => 'PENDING', + 'published_at' => null, + ]); + + $this->artisan('article:schedule'); + + $this->assertEquals('PENDING', $article->refresh()->status); + Event::assertNotDispatched(ArticlePublishedEvent::class); + } + + public function test_it_publishes_multiple_pending_articles(): void + { + Event::fake(); + + $articles = Article::factory()->count(3)->create([ + 'status' => 'PENDING', + 'published_at' => now()->subMinute(), + ]); + + $this->artisan('article:schedule'); + + foreach ($articles as $article) { + $this->assertEquals('PUBLISHED', $article->refresh()->status); + } + + Event::assertDispatchedTimes(ArticlePublishedEvent::class, 3); + } + + public function test_command_is_idempotent(): void + { + Event::fake(); + + $article = Article::factory()->create([ + 'status' => 'PENDING', + 'published_at' => now()->subMinute(), + ]); + + $this->artisan('article:schedule'); + $this->artisan('article:schedule'); + + Event::assertDispatchedTimes(ArticlePublishedEvent::class, 1); + $this->assertEquals('PUBLISHED', $article->refresh()->status); + } +} diff --git a/packages/Article/tests/Feature/Database/Seeders/ArticleSeederTest.php b/packages/Article/tests/Feature/Database/Seeders/ArticleSeederTest.php new file mode 100644 index 0000000..bfa9540 --- /dev/null +++ b/packages/Article/tests/Feature/Database/Seeders/ArticleSeederTest.php @@ -0,0 +1,17 @@ +seed(ArticleSeeder::class); + + $this->assertDatabaseCount(Article::class, 20); + } +} diff --git a/packages/Article/tests/Feature/Http/Controllers/ArticleControllerTest.php b/packages/Article/tests/Feature/Http/Controllers/ArticleControllerTest.php new file mode 100644 index 0000000..179f284 --- /dev/null +++ b/packages/Article/tests/Feature/Http/Controllers/ArticleControllerTest.php @@ -0,0 +1,155 @@ + 'https://cdn.4byte.dev/logo.png', + 'responsive' => [], + 'srcset' => '', + 'thumb' => null, + ], + user: $userData, + categories: [ + new CategoryData( + id: 1, + name: 'Category Test', + slug: 'category-test', + followers: 10, + isFollowing: true, + ), + ], + tags: [ + new TagData( + id: 2, + name: 'Tag Test', + slug: 'tag-test', + followers: 10, + isFollowing: true, + ), + ], + sources: [ + ['url' => 'https://4byte.dev', 'date' => now()->toString()], + ], + likes: 5, + dislikes: 7, + comments: 1, + isLiked: false, + isDisliked: true, + isSaved: false, + canUpdate: false, + canDelete: false, + published_at: now() + ); + + $articleService = Mockery::mock(ArticleService::class); + $articleService->shouldReceive('getId') + ->once() + ->with($slug) + ->andReturn($articleId); + + $articleService->shouldReceive('getData') + ->once() + ->with($articleId) + ->andReturn($articleData); + + $this->app->instance(ArticleService::class, $articleService); + + $metadata = Mockery::mock(BuildsMetadata::class); + $metadata->shouldReceive('generate')->once()->andReturn('seo-html'); + + $seoService = Mockery::mock(SeoService::class); + $seoService->shouldReceive('getArticleSeo') + ->once() + ->with($articleData, $userData) + ->andReturn($metadata); + + $this->app->instance(SeoService::class, $seoService); + + $response = $this->get(route('article.view', $slug)); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertInertia( + fn (Assert $page) => $page + ->component('Article/Detail') + + ->has('article') + + ->where('article.id', $articleId) + ->where('article.title', 'Test Article') + ->where('article.slug', $slug) + ->where('article.excerpt', 'Text excerpt') + ->where('article.content', 'Test content') + + ->where('article.image.image', 'https://cdn.4byte.dev/logo.png') + + ->where('article.user.id', 10) + ->where('article.user.name', 'Test User') + ->where('article.user.username', 'testuser') + ->where('article.user.followers', 10) + ->where('article.user.followings', 3) + ->where('article.user.isFollowing', true) + + ->has('article.categories', 1) + ->where('article.categories.0.name', 'Category Test') + ->where('article.categories.0.slug', 'category-test') + + ->has('article.tags', 1) + ->where('article.tags.0.name', 'Tag Test') + ->where('article.tags.0.slug', 'tag-test') + + ->has('article.sources', 1) + ->where('article.sources.0.url', 'https://4byte.dev') + ->where('article.sources.0.url', fn ($url) => $this->isValidUrl($url)) + ->where('article.sources.0.date', fn ($date) => $this->isValidDate($date)) + + ->where('article.likes', 5) + ->where('article.dislikes', 7) + ->where('article.comments', 1) + + ->where('article.isLiked', false) + ->where('article.isDisliked', true) + ->where('article.isSaved', false) + ->where('article.canUpdate', false) + ->where('article.canDelete', false) + ); + + $this->assertArrayHasKey('seo', $response->original->getData()); + } +} diff --git a/packages/Article/tests/Feature/Http/Controllers/ArticleCrudControllerTest.php b/packages/Article/tests/Feature/Http/Controllers/ArticleCrudControllerTest.php new file mode 100644 index 0000000..cd60c74 --- /dev/null +++ b/packages/Article/tests/Feature/Http/Controllers/ArticleCrudControllerTest.php @@ -0,0 +1,333 @@ + 'create_article']); + $user = User::factory()->create(); + $user->givePermissionTo('create_article'); + $this->actingAs($user); + + $mockCategories = [ + ['id' => 1, 'name' => 'PHP', 'slug' => 'php'], + ['id' => 2, 'name' => 'Laravel', 'slug' => 'laravel'], + ]; + + $mockTags = [ + ['id' => 1, 'name' => 'Coding', 'slug' => 'coding'], + ['id' => 2, 'name' => 'Testing', 'slug' => 'testing'], + ]; + + $feedService = Mockery::mock(FeedService::class); + $feedService->shouldReceive('categories')->once()->andReturn($mockCategories); + $feedService->shouldReceive('tags')->once()->andReturn($mockTags); + + $metadata = Mockery::mock(BuildsMetadata::class); + $metadata->shouldReceive('generate')->once()->andReturn('seo-html'); + $seoService = Mockery::mock(SeoService::class); + $seoService->shouldReceive('getArticleCreateSEO')->once()->andReturn($metadata); + + $this->app->instance(FeedService::class, $feedService); + $this->app->instance(SeoService::class, $seoService); + + $response = $this->get(route('article.crud.create.view')); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertInertia( + fn (Assert $page) => $page + ->component('Article/Create') + ->has('topCategories', 2) + ->where('topCategories.0.name', 'PHP') + ->where('topCategories.0.slug', 'php') + ->where('topCategories.1.name', 'Laravel') + ->has('topTags', 2) + ->where('topTags.0.name', 'Coding') + ->where('topTags.0.slug', 'coding') + ); + + $this->assertArrayHasKey('seo', $response->original->getData()); + } + + public function test_it_creates_published_article_with_relations_and_image(): void + { + Permission::firstOrCreate(['name' => 'create_article']); + $user = User::factory()->create(); + $user->givePermissionTo('create_article'); + $this->actingAs($user); + + $category = Category::factory()->create(['name' => 'Backend', 'slug' => 'backend']); + $tag = Tag::factory()->create(['name' => 'Security', 'slug' => 'security']); + + $image = UploadedFile::fake()->image('cover.jpg'); + + $payload = [ + 'title' => 'Published Title Updated', + 'excerpt' => $this->faker->paragraph(10), + 'content' => $this->faker->paragraph(20, true), + 'published' => true, + 'categories' => [$category->slug], + 'tags' => [$tag->slug], + 'image' => $image, + 'sources' => [ + ['url' => 'https://4byte.dev', 'date' => now()->toDateString()], + ], + ]; + + $response = $this->postJson(route('api.article.crud.create'), $payload); + + $response->assertOk(); + + $this->assertDatabaseHas('articles', [ + 'title' => 'Published Title Updated', + 'status' => 'PUBLISHED', + 'user_id' => $user->id, + ]); + + $article = Article::where('title', 'Published Title Updated')->first(); + + $this->assertNotEmpty($article->slug); + $this->assertNotNull($article->published_at); + + $this->assertTrue($article->categories->contains($category)); + $this->assertTrue($article->tags->contains($tag)); + } + + public function test_it_displays_article_edit_page_with_transformed_data(): void + { + Permission::firstOrCreate(['name' => 'update_article']); + $user = User::factory()->create(); + $user->givePermissionTo('update_article'); + $this->actingAs($user); + + $article = Article::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Legacy Code Refactoring', + 'excerpt' => 'Refactoring steps.', + 'content' => 'Deep dive into legacy code.', + 'status' => 'PUBLISHED', + 'published_at' => now(), + ]); + + $category = Category::factory()->create(['slug' => 'clean-code']); + $tag = Tag::factory()->create(['slug' => 'refactoring']); + + $article->categories()->attach($category); + $article->tags()->attach($tag); + + $feedService = Mockery::mock(FeedService::class); + $feedService->shouldReceive('categories')->once()->andReturn([]); + $feedService->shouldReceive('tags')->once()->andReturn([]); + + $metadata = Mockery::mock(BuildsMetadata::class); + $metadata->shouldReceive('generate')->once()->andReturn('seo-html'); + $seoService = Mockery::mock(SeoService::class); + $seoService->shouldReceive('getArticleEditSEO')->once()->andReturn($metadata); + + $this->app->instance(FeedService::class, $feedService); + $this->app->instance(SeoService::class, $seoService); + + $response = $this->get(route('article.crud.edit.view', ['article' => $article->slug])); + + $response->assertStatus(Response::HTTP_OK); + + $response->assertInertia( + fn (Assert $page) => $page + ->component('Article/Edit') + ->where('slug', $article->slug) + + ->has( + 'article', + fn (Assert $json) => $json + ->where('title', 'Legacy Code Refactoring') + ->where('excerpt', 'Refactoring steps.') + ->where('content', 'Deep dive into legacy code.') + ->where('published', true) + ->has('categories', 1) + ->where('categories.0', 'clean-code') + ->has('tags', 1) + ->where('tags.0', 'refactoring') + ->has('image') + ) + ); + } + + public function test_it_updates_article_status_and_regenerates_slug(): void + { + Permission::firstOrCreate(['name' => 'update_article']); + $user = User::factory()->create(); + $user->givePermissionTo('update_article'); + $this->actingAs($user); + + $article = Article::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Draft Title', + 'slug' => 'draft-title', + 'status' => 'DRAFT', + ]); + + $newCategory = Category::factory()->create(); + $newTag = Tag::factory()->create(); + + $newImage = UploadedFile::fake()->image('new-banner.jpg'); + + $payload = [ + 'title' => 'Published Title Updated', + 'excerpt' => $this->faker->paragraph(10), + 'content' => $this->faker->paragraph(20, true), + 'published' => true, + 'categories' => [$newCategory->slug], + 'tags' => [$newTag->slug], + 'image' => $newImage, + 'sources' => [ + ['url' => 'https://4byte.dev', 'date' => now()->toDateString()], + ], + ]; + + $response = $this->postJson(route('api.article.crud.edit', $article->slug), $payload); + + $response->assertOk(); + $response->assertJsonStructure(['slug']); + + $article->refresh(); + + $this->assertEquals('PUBLISHED', $article->status); + $this->assertNotNull($article->published_at); + + $this->assertNotEquals('draft-title', $article->slug); + + $this->assertTrue($article->categories->contains($newCategory)); + } + + public function test_it_handles_draft_creation_with_minimal_data(): void + { + Permission::firstOrCreate(['name' => 'create_article']); + $user = User::factory()->create(); + $user->givePermissionTo('create_article'); + + $this->actingAs($user); + + $payload = [ + 'title' => 'Just an idea', + 'published' => false, + ]; + + $response = $this->postJson(route('api.article.crud.create'), $payload); + + $response->assertOk(); + + $this->assertDatabaseHas('articles', [ + 'title' => 'Just an idea', + 'status' => 'DRAFT', + 'published_at' => null, + 'content' => null, + ]); + } + + public function test_it_returns_403_if_user_cannot_create_article(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = [ + 'title' => 'Hacked Title', + 'published' => true, + ]; + + $response = $this->postJson( + route('api.article.crud.create'), + $payload + ); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + } + + public function test_guest_cannot_access_article_create_page(): void + { + $response = $this->get( + route('article.crud.create.view'), + ); + + $response->assertStatus(Response::HTTP_FOUND); + } + + public function test_it_returns_403_if_user_cannot_update_article(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $article = Article::factory()->create(); + + $payload = [ + 'title' => 'Hacked Title', + 'published' => true, + ]; + + $response = $this->postJson( + route('api.article.crud.edit', $article->slug), + $payload + ); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + } + + public function test_guest_cannot_access_article_edit_page(): void + { + $article = Article::factory()->create(); + + $response = $this->get( + route('article.crud.edit.view', ['article' => $article->slug]) + ); + + $response->assertStatus(Response::HTTP_FOUND); + } + + public function test_it_returns_403_if_user_is_not_owner_of_article(): void + { + Permission::firstOrCreate(['name' => 'update_article']); + $user = User::factory()->create(); + $user->givePermissionTo('update_article'); + $this->actingAs($user); + + $otherUser = User::factory()->create(); + $article = Article::factory()->create([ + 'user_id' => $otherUser->id, + 'title' => 'Draft Title', + 'slug' => 'draft-title', + 'status' => 'DRAFT', + ]); + + $payload = [ + 'title' => 'Published Title Updated', + 'published' => false, + ]; + + $response = $this->postJson(route('api.article.crud.edit', $article->slug), $payload); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + + $article->refresh(); + + $this->assertEquals('Draft Title', $article->title); + } +} diff --git a/packages/Article/tests/Feature/Http/Requests/CreateRequestTest.php b/packages/Article/tests/Feature/Http/Requests/CreateRequestTest.php new file mode 100644 index 0000000..7e13d9c --- /dev/null +++ b/packages/Article/tests/Feature/Http/Requests/CreateRequestTest.php @@ -0,0 +1,320 @@ + 'This is a valid article title', + 'published' => false, + ]; + + $request = CreateRequest::create('/articles', 'POST', $data); + + $validator = Validator::make($request->all(), (new CreateRequest())->rules()); + + $this->assertFalse($validator->fails()); + } + + public function test_published_article_requires_all_fields(): void + { + $data = [ + 'title' => 'This is a valid article title', + 'published' => true, + 'excerpt' => 'Short', + 'content' => 'Content', + 'categories' => [], + 'tags' => [], + 'sources' => [], + ]; + + $request = CreateRequest::create('/articles', 'POST', $data); + + $validator = Validator::make($data, $request->rules()); + + $this->assertTrue($validator->fails()); + + $errors = $validator->errors(); + + $this->assertArrayHasKey('excerpt', $errors->toArray()); + $this->assertArrayHasKey('content', $errors->toArray()); + $this->assertArrayHasKey('categories', $errors->toArray()); + $this->assertArrayHasKey('tags', $errors->toArray()); + $this->assertArrayHasKey('image', $errors->toArray()); + $this->assertArrayHasKey('sources', $errors->toArray()); + } + + public function test_published_article_passes_with_valid_data(): void + { + $data = $this->publishedBaseData(); + + $request = CreateRequest::create('/articles', 'POST', $data); + $request->files->set('image', $data['image']); + + $validator = Validator::make($request->all(), (new CreateRequest())->rules()); + + $this->assertFalse($validator->fails()); + } + + public function test_it_generates_unique_slug(): void + { + Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-article', + ]); + + $request = CreateRequest::create('/articles', 'POST', [ + 'title' => 'Test Article', + ]); + + $slug = $request->createSlug(); + + $this->assertEquals('test-article-1', $slug); + } + + public function test_it_ignores_given_id_when_generating_slug(): void + { + $article = Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-article', + ]); + + $request = CreateRequest::create('/articles', 'POST', [ + 'title' => 'Test Article', + ]); + + $slug = $request->createSlug($article->id); + + $this->assertEquals('test-article', $slug); + } + + public function test_title_is_required(): void + { + $this->assertValidationError( + data: ['published' => false], + field: 'title' + ); + } + + public function test_title_must_be_string(): void + { + $this->assertValidationError( + data: ['title' => 123, 'published' => false], + field: 'title' + ); + } + + public function test_title_must_be_min_10_characters(): void + { + $this->assertValidationError( + data: ['title' => 'short', 'published' => false], + field: 'title' + ); + } + + public function test_excerpt_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['excerpt']), + field: 'excerpt' + ); + } + + public function test_excerpt_must_be_min_100_characters(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'excerpt' => str_repeat('a', 99), + ]), + field: 'excerpt' + ); + } + + public function test_content_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['content']), + field: 'content' + ); + } + + public function test_content_must_be_min_500_characters(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'content' => str_repeat('a', 499), + ]), + field: 'content' + ); + } + + public function test_categories_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['categories']), + field: 'categories' + ); + } + + public function test_categories_must_be_array(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => 'php', + ]), + field: 'categories' + ); + } + + public function test_categories_must_have_min_1_item(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => [], + ]), + field: 'categories' + ); + } + + public function test_categories_cannot_exceed_3_items(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => ['a', 'b', 'c', 'd'], + ]), + field: 'categories' + ); + } + + public function test_tags_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['tags']), + field: 'tags' + ); + } + + public function test_tags_cannot_exceed_3_items(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'tags' => ['a', 'b', 'c', 'd'], + ]), + field: 'tags' + ); + } + + public function test_image_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['image']), + field: 'image' + ); + } + + public function test_image_must_be_image_file(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'image' => UploadedFile::fake()->create('file.pdf'), + ]), + field: 'image' + ); + } + + public function test_sources_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['sources']), + field: 'sources' + ); + } + + public function test_sources_url_must_be_valid_url(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'sources' => [ + [ + 'url' => 'not-a-url', + 'date' => now()->toDateString(), + ], + ], + ]), + field: 'sources.0.url' + ); + } + + public function test_sources_date_must_be_valid_date(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'sources' => [ + [ + 'url' => 'https://example.com', + 'date' => 'invalid-date', + ], + ], + ]), + field: 'sources.0.date' + ); + } + + /** + * @param array $override + * @param array $except + * + * @return array{categories: string[], content: string, excerpt: string, image: \Illuminate\Http\Testing\File, published: bool, sources: array{date: string, url: string, tags: string[], title: string}} + */ + private function publishedBaseData(array $override = [], array $except = []): array + { + $data = [ + 'title' => 'This is a valid article title', + 'published' => true, + 'excerpt' => str_repeat('a', 100), + 'content' => str_repeat('b', 500), + 'categories' => ['php'], + 'tags' => ['laravel'], + 'image' => UploadedFile::fake()->image('image.jpg'), + 'sources' => [ + [ + 'url' => 'https://example.com', + 'date' => now()->toDateString(), + ], + ], + ]; + + foreach ($except as $key) { + unset($data[$key]); + } + + return array_merge($data, $override); + } + + /** + * @param array $data + * @param string $field + */ + private function assertValidationError(array $data, string $field): void + { + $request = CreateRequest::create('/articles', 'POST', $data); + + if (isset($data['image'])) { + $request->files->set('image', $data['image']); + } + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertTrue($validator->fails()); + + $this->assertArrayHasKey($field, $validator->errors()->toArray()); + } +} diff --git a/packages/Article/tests/Feature/Http/Requests/EditRequestTest.php b/packages/Article/tests/Feature/Http/Requests/EditRequestTest.php new file mode 100644 index 0000000..28c90f3 --- /dev/null +++ b/packages/Article/tests/Feature/Http/Requests/EditRequestTest.php @@ -0,0 +1,320 @@ + 'This is a valid article title', + 'published' => false, + ]; + + $request = EditRequest::create('/articles', 'POST', $data); + + $validator = Validator::make($request->all(), (new EditRequest())->rules()); + + $this->assertFalse($validator->fails()); + } + + public function test_published_article_requires_all_fields(): void + { + $data = [ + 'title' => 'This is a valid article title', + 'published' => true, + 'excerpt' => 'Short', + 'content' => 'Content', + 'categories' => [], + 'tags' => [], + 'sources' => [], + ]; + + $request = EditRequest::create('/articles', 'POST', $data); + + $validator = Validator::make($data, $request->rules()); + + $this->assertTrue($validator->fails()); + + $errors = $validator->errors(); + + $this->assertArrayHasKey('excerpt', $errors->toArray()); + $this->assertArrayHasKey('content', $errors->toArray()); + $this->assertArrayHasKey('categories', $errors->toArray()); + $this->assertArrayHasKey('tags', $errors->toArray()); + $this->assertArrayHasKey('image', $errors->toArray()); + $this->assertArrayHasKey('sources', $errors->toArray()); + } + + public function test_published_article_passes_with_valid_data(): void + { + $data = $this->publishedBaseData(); + + $request = EditRequest::create('/articles', 'POST', $data); + $request->files->set('image', $data['image']); + + $validator = Validator::make($request->all(), (new EditRequest())->rules()); + + $this->assertFalse($validator->fails()); + } + + public function test_it_generates_unique_slug(): void + { + Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-article', + ]); + + $request = EditRequest::create('/articles', 'POST', [ + 'title' => 'Test Article', + ]); + + $slug = $request->createSlug(); + + $this->assertEquals('test-article-1', $slug); + } + + public function test_it_ignores_given_id_when_generating_slug(): void + { + $article = Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-article', + ]); + + $request = EditRequest::create('/articles', 'POST', [ + 'title' => 'Test Article', + ]); + + $slug = $request->createSlug($article->id); + + $this->assertEquals('test-article', $slug); + } + + public function test_title_is_required(): void + { + $this->assertValidationError( + data: ['published' => false], + field: 'title' + ); + } + + public function test_title_must_be_string(): void + { + $this->assertValidationError( + data: ['title' => 123, 'published' => false], + field: 'title' + ); + } + + public function test_title_must_be_min_10_characters(): void + { + $this->assertValidationError( + data: ['title' => 'short', 'published' => false], + field: 'title' + ); + } + + public function test_excerpt_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['excerpt']), + field: 'excerpt' + ); + } + + public function test_excerpt_must_be_min_100_characters(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'excerpt' => str_repeat('a', 99), + ]), + field: 'excerpt' + ); + } + + public function test_content_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['content']), + field: 'content' + ); + } + + public function test_content_must_be_min_500_characters(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'content' => str_repeat('a', 499), + ]), + field: 'content' + ); + } + + public function test_categories_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['categories']), + field: 'categories' + ); + } + + public function test_categories_must_be_array(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => 'php', + ]), + field: 'categories' + ); + } + + public function test_categories_must_have_min_1_item(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => [], + ]), + field: 'categories' + ); + } + + public function test_categories_cannot_exceed_3_items(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'categories' => ['a', 'b', 'c', 'd'], + ]), + field: 'categories' + ); + } + + public function test_tags_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['tags']), + field: 'tags' + ); + } + + public function test_tags_cannot_exceed_3_items(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'tags' => ['a', 'b', 'c', 'd'], + ]), + field: 'tags' + ); + } + + public function test_image_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['image']), + field: 'image' + ); + } + + public function test_image_must_be_image_file(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'image' => UploadedFile::fake()->create('file.pdf'), + ]), + field: 'image' + ); + } + + public function test_sources_is_required_when_published(): void + { + $this->assertValidationError( + data: $this->publishedBaseData(except: ['sources']), + field: 'sources' + ); + } + + public function test_sources_url_must_be_valid_url(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'sources' => [ + [ + 'url' => 'not-a-url', + 'date' => now()->toDateString(), + ], + ], + ]), + field: 'sources.0.url' + ); + } + + public function test_sources_date_must_be_valid_date(): void + { + $this->assertValidationError( + data: $this->publishedBaseData([ + 'sources' => [ + [ + 'url' => 'https://example.com', + 'date' => 'invalid-date', + ], + ], + ]), + field: 'sources.0.date' + ); + } + + /** + * @param array $override + * @param array $except + * + * @return array{categories: string[], content: string, excerpt: string, image: \Illuminate\Http\Testing\File, published: bool, sources: array{date: string, url: string, tags: string[], title: string}} + */ + private function publishedBaseData(array $override = [], array $except = []): array + { + $data = [ + 'title' => 'This is a valid article title', + 'published' => true, + 'excerpt' => str_repeat('a', 100), + 'content' => str_repeat('b', 500), + 'categories' => ['php'], + 'tags' => ['laravel'], + 'image' => UploadedFile::fake()->image('image.jpg'), + 'sources' => [ + [ + 'url' => 'https://example.com', + 'date' => now()->toDateString(), + ], + ], + ]; + + foreach ($except as $key) { + unset($data[$key]); + } + + return array_merge($data, $override); + } + + /** + * @param array $data + * @param string $field + */ + private function assertValidationError(array $data, string $field): void + { + $request = EditRequest::create('/articles', 'POST', $data); + + if (isset($data['image'])) { + $request->files->set('image', $data['image']); + } + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertTrue($validator->fails()); + + $this->assertArrayHasKey($field, $validator->errors()->toArray()); + } +} diff --git a/packages/Article/tests/TestCase.php b/packages/Article/tests/TestCase.php new file mode 100644 index 0000000..a68e0cb --- /dev/null +++ b/packages/Article/tests/TestCase.php @@ -0,0 +1,26 @@ + 'https://cdn.4byte.dev/logo.png', + 'responsive' => [], + 'srcset' => '', + 'thumb' => null, + ], + user: $userData, + categories: [ + new CategoryData( + id: 3, + name: 'Category Test', + slug: 'category-test', + followers: 10, + isFollowing: true + ), + ], + tags: [ + new TagData( + id: 2, + name: 'Tag Test', + slug: 'tag-test', + followers: 3, + isFollowing: false + ), + ], + sources: [ + ['url' => 'https://4byte.dev', 'date' => now()->toString()], + ], + likes: 5, + dislikes: 7, + comments: 1, + isLiked: false, + isDisliked: true, + isSaved: false, + canUpdate: false, + canDelete: false, + published_at: now() + ); + + $this->assertSame(2, $articleData->id); + $this->assertSame('Test Article', $articleData->title); + $this->assertSame('test-article', $articleData->slug); + $this->assertSame('Text excerpt', $articleData->excerpt); + $this->assertSame('Test content', $articleData->content); + + $this->assertSame('https://cdn.4byte.dev/logo.png', $articleData->image['image']); + + $this->assertSame(5, $articleData->likes); + $this->assertSame(7, $articleData->dislikes); + $this->assertSame(1, $articleData->comments); + + $this->assertFalse($articleData->isLiked); + $this->assertTrue($articleData->isDisliked); + $this->assertFalse($articleData->isSaved); + + $this->assertFalse($articleData->canUpdate); + $this->assertFalse($articleData->canDelete); + + $this->assertInstanceOf(Carbon::class, $articleData->published_at); + $this->assertInstanceOf(UserData::class, $articleData->user); + + $this->assertSame(10, $articleData->user->id); + $this->assertSame('Test User', $articleData->user->name); + $this->assertSame('testuser', $articleData->user->username); + $this->assertSame('', $articleData->user->avatar); + + $this->assertSame(10, $articleData->user->followers); + $this->assertSame(3, $articleData->user->followings); + $this->assertTrue($articleData->user->isFollowing); + + $this->assertInstanceOf(Carbon::class, $articleData->user->created_at); + + $this->assertCount(1, $articleData->categories); + $this->assertSame('Category Test', $articleData->categories[0]->name); + $this->assertSame('category-test', $articleData->categories[0]->slug); + + $this->assertCount(1, $articleData->tags); + $this->assertSame('Tag Test', $articleData->tags[0]->name); + $this->assertSame('tag-test', $articleData->tags[0]->slug); + + $this->assertCount(1, $articleData->sources); + $this->assertSame('https://4byte.dev', $articleData->sources[0]['url']); + $this->assertTrue($this->isValidUrl($articleData->sources[0]['url'])); + $this->assertTrue($this->isValidDate($articleData->sources[0]['date'])); + + $this->assertSame('article', $articleData->type); + } + + public function test_it_creates_data_from_model_without_id_by_default(): void + { + $article = Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-slug', + 'excerpt' => 'Test Excerpt', + 'content' => 'Test Content', + ]); + + $user = User::factory()->create([ + 'name' => 'User Name', + 'username' => 'username', + ]); + + $user = UserData::fromModel($user); + + $articleData = ArticleData::fromModel($article, $user); + + $this->assertSame(0, $articleData->id); + + $this->assertSame('Test Article', $articleData->title); + $this->assertSame('test-slug', $articleData->slug); + $this->assertSame('Test Excerpt', $articleData->excerpt); + $this->assertSame('Test Content', $articleData->content); + + $this->assertInstanceOf(UserData::class, $articleData->user); + $this->assertSame($user->id, $articleData->user->id); + $this->assertSame('User Name', $articleData->user->name); + $this->assertSame('username', $articleData->user->username); + + $this->assertSame(0, $articleData->likes); + $this->assertSame(0, $articleData->dislikes); + $this->assertSame(0, $articleData->comments); + + $this->assertFalse($articleData->isLiked); + $this->assertFalse($articleData->isDisliked); + $this->assertFalse($articleData->isSaved); + + $this->assertFalse($articleData->canUpdate); + $this->assertFalse($articleData->canDelete); + } + + public function test_it_sets_id_when_flag_is_true(): void + { + $article = Article::factory()->create(); + + $user = User::factory()->create(); + + $user = UserData::fromModel($user); + + $articleData = ArticleData::fromModel($article, $user, true); + + $this->assertSame($article->id, $articleData->id); + } + + public function test_it_uses_model_methods_for_followers_and_follow_state(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + $userData = UserData::fromModel($user); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->id = 10; + $article->title = 'Test Article'; + $article->slug = 'test-article'; + + $article->setRelation('categories', collect()); + $article->setRelation('tags', collect()); + + $article->shouldReceive('likesCount') + ->once() + ->andReturn(15); + + $article->shouldReceive('dislikesCount') + ->once() + ->andReturn(3); + + $article->shouldReceive('isLikedBy') + ->once() + ->with($user->id) + ->andReturn(true); + + $article->shouldReceive('isDislikedBy') + ->once() + ->with($user->id) + ->andReturn(false); + + $data = ArticleData::fromModel($article, $userData, true); + + $this->assertSame(10, $data->id); + $this->assertSame(15, $data->likes); + $this->assertSame(3, $data->dislikes); + $this->assertTrue($data->isLiked); + $this->assertFalse($data->isDisliked); + } + + public function test_it_sets_like_and_dislike_state_as_false_for_guest_user(): void + { + $user = User::factory()->create(); + + $userData = UserData::fromModel($user); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->id = 1; + $article->title = 'Guest Article'; + $article->slug = 'guest-article'; + + $article->setRelation('categories', collect()); + $article->setRelation('tags', collect()); + + $article->shouldReceive('likesCount') + ->once() + ->andReturn(0); + + $article->shouldReceive('dislikesCount') + ->once() + ->andReturn(0); + + $article->shouldReceive('isLikedBy') + ->once() + ->with(null) + ->andReturn(false); + + $article->shouldReceive('isDislikedBy') + ->once() + ->with(null) + ->andReturn(false); + + $data = ArticleData::fromModel($article, $userData); + + $this->assertFalse($data->isLiked); + $this->assertFalse($data->isDisliked); + } +} diff --git a/packages/Article/tests/Unit/Database/ArticleFactoryTest.php b/packages/Article/tests/Unit/Database/ArticleFactoryTest.php new file mode 100644 index 0000000..ec606f8 --- /dev/null +++ b/packages/Article/tests/Unit/Database/ArticleFactoryTest.php @@ -0,0 +1,64 @@ +create(); + + $this->assertInstanceOf(Article::class, $article); + $this->assertNotNull($article->excerpt); + $this->assertNotNull($article->content); + $this->assertContains($article->status, ['DRAFT', 'PUBLISHED', 'PENDING']); + $this->assertIsArray($article->sources); + $this->assertTrue($this->isValidUrl($article->sources[0]['url'])); + $this->assertTrue($this->isValidDate($article->sources[0]['date'])); + } + + public function test_it_creates_article_linked_to_user(): void + { + $user = User::factory()->create(); + $article = Article::factory()->create([ + 'user_id' => $user->id, + ]); + + $this->assertEquals($user->id, $article->user_id); + } + + public function test_slug_is_a_valid_slug(): void + { + $tag = Article::factory()->create(); + + $this->assertSame( + Str::slug($tag->title), + $tag->slug + ); + } + + public function test_slug_contains_only_slug_characters(): void + { + $article = Article::factory()->create(); + + $this->assertMatchesRegularExpression( + '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', + $article->slug + ); + } + + public function test_factory_creates_unique_articles(): void + { + $articles = Article::factory()->count(10)->create(); + + $this->assertCount( + 10, + $articles->pluck('slug')->unique() + ); + } +} diff --git a/packages/Article/tests/Unit/Events/ArticlePublishedEventTest.php b/packages/Article/tests/Unit/Events/ArticlePublishedEventTest.php new file mode 100644 index 0000000..7d7e887 --- /dev/null +++ b/packages/Article/tests/Unit/Events/ArticlePublishedEventTest.php @@ -0,0 +1,31 @@ +make(); + $event = new ArticlePublishedEvent($article); + + $this->assertSame($article, $event->article); + } + + public function test_event_dispatch(): void + { + Event::fake(); + + $article = Article::factory()->create(); + ArticlePublishedEvent::dispatch($article); + + Event::assertDispatched(ArticlePublishedEvent::class, function ($event) use ($article) { + return $event->article->id === $article->id; + }); + } +} diff --git a/packages/Article/tests/Unit/Listeners/ArticlePublishedListenerTest.php b/packages/Article/tests/Unit/Listeners/ArticlePublishedListenerTest.php new file mode 100644 index 0000000..307fb7b --- /dev/null +++ b/packages/Article/tests/Unit/Listeners/ArticlePublishedListenerTest.php @@ -0,0 +1,32 @@ +create(); + $event = new ArticlePublishedEvent($article); + $listener = new ArticlePublishedListener(); + + $listener->handle($event); + + NotificationFacade::assertSentTo( + $article->user, + ArticlePublishedNotification::class, + function ($notification, $channels) use ($article) { + return $notification->article->id === $article->id; + } + ); + } +} diff --git a/packages/Article/tests/Unit/Models/ArticleTest.php b/packages/Article/tests/Unit/Models/ArticleTest.php new file mode 100644 index 0000000..8d6651f --- /dev/null +++ b/packages/Article/tests/Unit/Models/ArticleTest.php @@ -0,0 +1,126 @@ +assertEquals( + [ + 'title', + 'slug', + 'excerpt', + 'content', + 'status', + 'sources', + 'published_at', + 'user_id', + ], + $article->getFillable() + ); + } + + public function test_casts_are_correct(): void + { + $article = new Article(); + + $this->assertEquals('datetime', $article->getCasts()['published_at']); + $this->assertEquals('array', $article->getCasts()['sources']); + } + + public function test_it_belongs_to_user(): void + { + $user = User::factory()->create(); + $article = Article::factory()->create(['user_id' => $user->id]); + + $this->assertInstanceOf(User::class, $article->user); + $this->assertEquals($user->id, $article->user->id); + } + + public function test_it_belongs_to_many_categories(): void + { + $article = Article::factory()->create(); + $category = Category::factory()->create(); + + $article->categories()->attach($category); + + $this->assertTrue($article->categories->contains($category)); + } + + public function test_it_belongs_to_many_tags(): void + { + $article = Article::factory()->create(); + $tag = Tag::factory()->create(); + + $article->tags()->attach($tag); + + $this->assertTrue($article->tags->contains($tag)); + } + + public function test_it_logs_activity_on_create(): void + { + Article::factory()->create([ + 'title' => 'New Article', + ]); + + $activity = Activity::orderBy('id', 'desc')->first(); + + $this->assertSame('article', $activity->log_name); + $this->assertSame('created', $activity->description); + $this->assertSame('New Article', $activity->properties['attributes']['title']); + } + + public function test_it_logs_only_dirty_attributes_on_update(): void + { + $article = Article::factory()->create(['title' => 'Old Title']); + + $article->update(['title' => 'New Title']); + + $activity = Activity::orderBy('id', 'desc')->first(); + + $this->assertSame('updated', $activity->description); + $this->assertSame('New Title', $activity->properties['attributes']['title']); + $this->assertSame('Old Title', $activity->properties['old']['title']); + } + + public function test_it_does_not_log_when_nothing_changes(): void + { + $article = Article::factory()->create(); + + $initialCount = Activity::count(); + + $article->update(['title' => $article->title]); + + $this->assertSame($initialCount, Activity::count()); + } + + public function test_it_is_searchable_only_when_published(): void + { + $draftArticle = Article::factory()->create(['status' => 'DRAFT']); + $publishedArticle = Article::factory()->create(['status' => 'PUBLISHED']); + + $this->assertFalse($draftArticle->shouldBeSearchable()); + $this->assertTrue($publishedArticle->shouldBeSearchable()); + } + + public function test_searchable_array_structure(): void + { + $article = Article::factory()->create(); + + $array = $article->toSearchableArray(); + + $this->assertArrayHasKey('id', $array); + $this->assertArrayHasKey('title', $array); + $this->assertEquals($article->title, $array['title']); + } +} diff --git a/packages/Article/tests/Unit/Notifications/ArticlePublishedNotificationTest.php b/packages/Article/tests/Unit/Notifications/ArticlePublishedNotificationTest.php new file mode 100644 index 0000000..9e151c7 --- /dev/null +++ b/packages/Article/tests/Unit/Notifications/ArticlePublishedNotificationTest.php @@ -0,0 +1,31 @@ +make([ + 'title' => 'Test Article', + 'slug' => 'test-article', + 'excerpt' => 'Test Excerpt', + ]); + + $notification = new ArticlePublishedNotification($article); + + $this->assertEquals(['mail'], $notification->via()); + + $mailData = $notification->toMail(); + + $this->assertInstanceOf(\Illuminate\Notifications\Messages\MailMessage::class, $mailData); + + $arrayData = $notification->toArray(); + $this->assertEquals('Test Article', $arrayData['title']); + $this->assertStringContainsString('test-article', $arrayData['url']); + } +} diff --git a/packages/Article/tests/Unit/Observers/ArticleObserverTest.php b/packages/Article/tests/Unit/Observers/ArticleObserverTest.php new file mode 100644 index 0000000..3efe506 --- /dev/null +++ b/packages/Article/tests/Unit/Observers/ArticleObserverTest.php @@ -0,0 +1,75 @@ +gorse = Mockery::mock(GorseService::class); + $this->observer = new ArticleObserver($this->gorse); + } + + public function test_saved_inserts_item_to_gorse_if_published(): void + { + $article = Article::factory()->make(['status' => 'PUBLISHED', 'id' => 1]); + $article->setRelation('tags', collect([])); + $article->setRelation('categories', collect([])); + + $this->gorse->shouldReceive('insertItem') + ->once() + ->with(Mockery::type(GorseItem::class)); + + $this->observer->saved($article); + } + + public function test_saved_does_not_insert_if_not_published(): void + { + $article = Article::factory()->make(['status' => 'DRAFT']); + + $this->gorse->shouldNotReceive('insertItem'); + + $this->observer->saved($article); + } + + public function test_updated_clears_cache(): void + { + $article = Article::factory()->make(['id' => 1]); + + Cache::shouldReceive('forget') + ->once() + ->with("article:1"); + + $this->observer->updated($article); + } + + public function test_deleted_removes_from_gorse_and_clears_cache(): void + { + $article = Article::factory()->make(['id' => 1, 'slug' => 'slug']); + + $this->gorse->shouldReceive('deleteItem') + ->once() + ->with("article:1"); + + Cache::shouldReceive('forget')->with("article:slug:id"); + Cache::shouldReceive('forget')->with("article:1"); + Cache::shouldReceive('forget')->with("article:1:likes"); + Cache::shouldReceive('forget')->with("article:1:dislikes"); + + $this->observer->deleted($article); + } +} diff --git a/packages/Article/tests/Unit/Policies/ArticlePolicyTest.php b/packages/Article/tests/Unit/Policies/ArticlePolicyTest.php new file mode 100644 index 0000000..9f85148 --- /dev/null +++ b/packages/Article/tests/Unit/Policies/ArticlePolicyTest.php @@ -0,0 +1,302 @@ +policy = new ArticlePolicy(); + } + + public function test_view_any(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('view_any_article')->andReturn(true); + $this->assertTrue($this->policy->viewAny($user)); + } + + public function test_view_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('view_any_article')->andReturn(false); + $user->shouldReceive('can')->with('view_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->view($user, $article)); + $this->assertFalse($this->policy->viewAny($user)); + } + + public function test_view_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('view_any_article')->andReturn(false); + $user->shouldReceive('can')->with('view_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->view($user, $article)); + $this->assertFalse($this->policy->viewAny($user)); + } + + public function test_view_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('view_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->view($user, $article)); + $this->assertTrue($this->policy->viewAny($user)); + } + + public function test_create(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('create_article')->andReturn(true); + + $this->assertTrue($this->policy->create($user)); + } + + public function test_update_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('update_any_article')->andReturn(false); + $user->shouldReceive('can')->with('update_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->update($user, $article)); + } + + public function test_update_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('update_any_article')->andReturn(false); + $user->shouldReceive('can')->with('update_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->update($user, $article)); + } + + public function test_update_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('update_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->update($user, $article)); + } + + public function test_delete_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('delete_any_article')->andReturn(false); + $user->shouldReceive('can')->with('delete_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->delete($user, $article)); + $this->assertFalse($this->policy->deleteAny($user)); + } + + public function test_delete_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('delete_any_article')->andReturn(false); + $user->shouldReceive('can')->with('delete_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->delete($user, $article)); + $this->assertFalse($this->policy->deleteAny($user)); + } + + public function test_delete_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('delete_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->delete($user, $article)); + $this->assertTrue($this->policy->deleteAny($user)); + } + + public function test_force_delete_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('force_delete_any_article')->andReturn(false); + $user->shouldReceive('can')->with('force_delete_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->forceDelete($user, $article)); + $this->assertFalse($this->policy->forceDeleteAny($user)); + } + + public function test_force_delete_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('force_delete_any_article')->andReturn(false); + $user->shouldReceive('can')->with('force_delete_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->forceDelete($user, $article)); + $this->assertFalse($this->policy->forceDeleteAny($user)); + } + + public function test_force_delete_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('force_delete_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->forceDelete($user, $article)); + $this->assertTrue($this->policy->forceDeleteAny($user)); + } + + public function test_restore_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('restore_any_article')->andReturn(false); + $user->shouldReceive('can')->with('restore_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->restore($user, $article)); + $this->assertFalse($this->policy->restoreAny($user)); + } + + public function test_restore_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('restore_any_article')->andReturn(false); + $user->shouldReceive('can')->with('restore_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->restore($user, $article)); + $this->assertFalse($this->policy->restoreAny($user)); + } + + public function test_restore_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('restore_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->restore($user, $article)); + $this->assertTrue($this->policy->restoreAny($user)); + } + + public function test_replicate_own_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('replicate_any_article')->andReturn(false); + $user->shouldReceive('can')->with('replicate_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 1; + + $this->assertTrue($this->policy->replicate($user, $article)); + } + + public function test_replicate_others_article(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('can')->with('replicate_any_article')->andReturn(false); + $user->shouldReceive('can')->with('replicate_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + $article->user_id = 2; + + $this->assertFalse($this->policy->replicate($user, $article)); + } + + public function test_replicate_any_permission_overrides_ownership(): void + { + /** @var User|MockInterface $user */ + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('can')->with('replicate_any_article')->andReturn(true); + + /** @var Article|MockInterface $article */ + $article = Mockery::mock(Article::class)->makePartial(); + + $this->assertTrue($this->policy->replicate($user, $article)); + } +} diff --git a/packages/Article/tests/Unit/Services/ArticleServiceTest.php b/packages/Article/tests/Unit/Services/ArticleServiceTest.php new file mode 100644 index 0000000..f69abdf --- /dev/null +++ b/packages/Article/tests/Unit/Services/ArticleServiceTest.php @@ -0,0 +1,43 @@ +service = app(ArticleService::class); + } + + public function test_get_data_returns_article_data(): void + { + $article = Article::factory()->create(['status' => 'PUBLISHED']); + + $data = $this->service->getData($article->id); + + $this->assertEquals($article->title, $data->title); + $this->assertEquals($article->slug, $data->slug); + $this->assertTrue(Cache::has("article:{$article->id}")); + } + + public function test_it_can_get_article_id_by_slug(): void + { + $article = Article::factory()->create([ + 'status' => 'PUBLISHED', + ]); + + $id = $this->service->getId($article->slug); + + $this->assertEquals($article->id, $id); + $this->assertTrue(Cache::has("article:{$article->slug}:id")); + } +} diff --git a/packages/Category/tests/Unit/Data/CategoryDataTest.php b/packages/Category/tests/Unit/Data/CategoryDataTest.php index 7b1ce9c..f4a069d 100644 --- a/packages/Category/tests/Unit/Data/CategoryDataTest.php +++ b/packages/Category/tests/Unit/Data/CategoryDataTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Auth; use Mockery; +use Mockery\MockInterface; use Packages\Category\Data\CategoryData; use Packages\Category\Models\Category; use Packages\Category\Tests\TestCase; @@ -64,6 +65,7 @@ public function test_it_uses_model_methods_for_followers_and_follow_state(): voi $userId = 123; Auth::shouldReceive('id')->once()->andReturn($userId); + /** @var Category|MockInterface $category */ $category = Mockery::mock(Category::class)->makePartial(); $category->id = 99; $category->name = 'PHP'; @@ -89,6 +91,7 @@ public function test_it_handles_guest_user(): void { Auth::shouldReceive('id')->once()->andReturn(null); + /** @var Category|MockInterface $category */ $category = Mockery::mock(Category::class)->makePartial(); $category->id = 1; $category->name = 'Guest'; diff --git a/packages/Tag/tests/Unit/Data/TagDataTest.php b/packages/Tag/tests/Unit/Data/TagDataTest.php index ec7bfc9..de2a9d0 100644 --- a/packages/Tag/tests/Unit/Data/TagDataTest.php +++ b/packages/Tag/tests/Unit/Data/TagDataTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Auth; use Mockery; +use Mockery\MockInterface; use Packages\Tag\Data\TagData; use Packages\Tag\Models\Tag; use Packages\Tag\Tests\TestCase; @@ -64,6 +65,7 @@ public function test_it_uses_model_methods_for_followers_and_follow_state(): voi $userId = 123; Auth::shouldReceive('id')->once()->andReturn($userId); + /** @var Tag|MockInterface $tag */ $tag = Mockery::mock(Tag::class)->makePartial(); $tag->id = 99; $tag->name = 'PHP'; @@ -89,6 +91,7 @@ public function test_it_handles_guest_user(): void { Auth::shouldReceive('id')->once()->andReturn(null); + /** @var Tag|MockInterface $tag */ $tag = Mockery::mock(Tag::class)->makePartial(); $tag->id = 1; $tag->name = 'Guest'; diff --git a/packages/Tag/tests/Unit/Observers/TagObserverTest.php b/packages/Tag/tests/Unit/Observers/TagObserverTest.php index 8eee7c7..6df1529 100644 --- a/packages/Tag/tests/Unit/Observers/TagObserverTest.php +++ b/packages/Tag/tests/Unit/Observers/TagObserverTest.php @@ -9,6 +9,14 @@ class TagObserverTest extends TestCase { + private TagObserver $observer; + + protected function setUp(): void + { + parent::setUp(); + $this->observer = new TagObserver(); + } + public function test_it_clears_old_slug_cache_when_slug_changes(): void { Cache::shouldReceive('forget') @@ -26,7 +34,7 @@ public function test_it_clears_old_slug_cache_when_slug_changes(): void $tag->slug = 'new-slug'; - (new TagObserver())->updated($tag); + $this->observer->updated($tag); } public function test_it_does_not_clear_slug_cache_if_slug_not_changed(): void @@ -44,7 +52,7 @@ public function test_it_does_not_clear_slug_cache_if_slug_not_changed(): void $tag->slug = 'slug'; $tag->syncOriginal(); - (new TagObserver())->updated($tag); + $this->observer->updated($tag); } public function test_update_always_clears_id_cache(): void @@ -58,7 +66,7 @@ public function test_update_always_clears_id_cache(): void $tag->slug = 'slug'; $tag->syncOriginal(); - (new TagObserver())->updated($tag); + $this->observer->updated($tag); } public function test_it_clears_all_related_cache_on_delete(): void @@ -73,6 +81,6 @@ public function test_it_clears_all_related_cache_on_delete(): void $tag->id = 1; $tag->slug = 'slug'; - (new TagObserver())->deleted($tag); + $this->observer->deleted($tag); } } diff --git a/phpunit.xml b/phpunit.xml index f02789f..8479fd4 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,7 @@ packages/Tag/tests packages/Category/tests + packages/Article/tests