From efc4ac35aff1620c717bcd5b4b8ca1487e751f28 Mon Sep 17 00:00:00 2001 From: Bryce Meyer Date: Sat, 22 Nov 2025 15:00:31 +0100 Subject: [PATCH] added category article link --- code/app/Athenia/Models/Category.php | 14 ++ code/app/Athenia/Models/Wiki/Article.php | 24 +++ .../Repositories/Wiki/ArticleRepository.php | 53 ++++++ ...52_create_article_category_pivot_table.php | 43 +++++ .../Http/Article/ArticleCreateTest.php | 138 +++++++++++++++ .../Http/Article/ArticleUpdateTest.php | 164 ++++++++++++++++++ 6 files changed, 436 insertions(+) create mode 100644 code/database/migrations/2025_11_22_142752_create_article_category_pivot_table.php diff --git a/code/app/Athenia/Models/Category.php b/code/app/Athenia/Models/Category.php index 031bd21a..9fe34486 100644 --- a/code/app/Athenia/Models/Category.php +++ b/code/app/Athenia/Models/Category.php @@ -6,6 +6,8 @@ use App\Athenia\Contracts\Models\HasValidationRulesContract; use App\Athenia\Models\BaseModelAbstract; use App\Athenia\Models\Traits\HasValidationRules; +use App\Models\Wiki\Article; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder; /** @@ -52,6 +54,18 @@ class Category extends BaseModelAbstract implements HasValidationRulesContract { use HasValidationRules; + /** + * All articles associated with this category + * + * @return BelongsToMany + */ + public function articles() : BelongsToMany + { + return $this->belongsToMany(Article::class, 'article_category') + ->withPivot('relevance') + ->withTimestamps(); + } + /** * @param mixed ...$params * @return array diff --git a/code/app/Athenia/Models/Wiki/Article.php b/code/app/Athenia/Models/Wiki/Article.php index bb77b55f..a49416ae 100644 --- a/code/app/Athenia/Models/Wiki/Article.php +++ b/code/app/Athenia/Models/Wiki/Article.php @@ -9,12 +9,14 @@ use App\Athenia\Models\BaseModelAbstract; use App\Athenia\Models\Traits\CanBeIndexed; use App\Athenia\Models\Traits\HasValidationRules; +use App\Models\Category; use App\Models\User\User; use App\Models\Wiki\ArticleIteration; use App\Models\Wiki\ArticleModification; use App\Models\Wiki\ArticleVersion; use Eloquent; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -102,6 +104,18 @@ public function versions() : HasMany ->orderByDesc('created_at')->orderByDesc('id'); } + /** + * All categories associated with this article + * + * @return BelongsToMany + */ + public function categories() : BelongsToMany + { + return $this->belongsToMany(Category::class, 'article_category') + ->withPivot('relevance') + ->withTimestamps(); + } + /** * Gets the content of the article * @@ -168,6 +182,16 @@ public function buildModelValidationRules(...$params): array 'string', 'max:120', ], + 'categories' => [ + 'array', + ], + 'categories.*.category_id' => [ + 'integer', + 'exists:categories,id', + ], + 'categories.*.relevance' => [ + 'numeric', + ], ], static::VALIDATION_RULES_CREATE => [ static::VALIDATION_PREPEND_REQUIRED => [ diff --git a/code/app/Athenia/Repositories/Wiki/ArticleRepository.php b/code/app/Athenia/Repositories/Wiki/ArticleRepository.php index f8652aae..1c03c9f4 100644 --- a/code/app/Athenia/Repositories/Wiki/ArticleRepository.php +++ b/code/app/Athenia/Repositories/Wiki/ArticleRepository.php @@ -4,7 +4,9 @@ namespace App\Athenia\Repositories\Wiki; use App\Athenia\Contracts\Repositories\Wiki\ArticleRepositoryContract; +use App\Athenia\Models\BaseModelAbstract; use App\Athenia\Repositories\BaseRepositoryAbstract; +use App\Athenia\Traits\CanGetAndUnset; use App\Models\Wiki\Article; use App\Repositories\Traits\NotImplemented; use Psr\Log\LoggerInterface as LogContract; @@ -16,6 +18,7 @@ class ArticleRepository extends BaseRepositoryAbstract implements ArticleRepositoryContract { use \App\Athenia\Repositories\Traits\NotImplemented\Delete; + use CanGetAndUnset; /** * ArticleRepository constructor. @@ -26,4 +29,54 @@ public function __construct(Article $model, LogContract $log) { parent::__construct($model, $log); } + + /** + * Override create to handle categories sync + * + * @param array $data + * @param BaseModelAbstract|null $relatedModel + * @param array $forcedValues + * @return BaseModelAbstract + */ + public function create(array $data = [], BaseModelAbstract $relatedModel = null, array $forcedValues = []): BaseModelAbstract + { + $categories = $this->getAndUnset($data, 'categories'); + + /** @var Article $article */ + $article = parent::create($data, $relatedModel, $forcedValues); + + if ($categories !== null) { + $syncData = collect($categories)->mapWithKeys(fn($cat) => [ + $cat['category_id'] => array_filter(['relevance' => $cat['relevance'] ?? null]) + ])->toArray(); + $article->categories()->sync($syncData); + } + + return $article; + } + + /** + * Override update to handle categories sync + * + * @param BaseModelAbstract $model + * @param array $data + * @param array $forcedValues + * @return BaseModelAbstract + */ + public function update(BaseModelAbstract $model, array $data, array $forcedValues = []): BaseModelAbstract + { + $categories = $this->getAndUnset($data, 'categories'); + + /** @var Article $article */ + $article = parent::update($model, $data, $forcedValues); + + if ($categories !== null) { + $syncData = collect($categories)->mapWithKeys(fn($cat) => [ + $cat['category_id'] => array_filter(['relevance' => $cat['relevance'] ?? null]) + ])->toArray(); + $article->categories()->sync($syncData); + } + + return $article; + } } \ No newline at end of file diff --git a/code/database/migrations/2025_11_22_142752_create_article_category_pivot_table.php b/code/database/migrations/2025_11_22_142752_create_article_category_pivot_table.php new file mode 100644 index 00000000..b45cac7e --- /dev/null +++ b/code/database/migrations/2025_11_22_142752_create_article_category_pivot_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedInteger('article_id'); + $table->unsignedInteger('category_id'); + $table->float('relevance')->default(1.0); + $table->timestamps(); + + $table->foreign('article_id') + ->references('id') + ->on('articles') + ->onDelete('cascade'); + + $table->foreign('category_id') + ->references('id') + ->on('categories') + ->onDelete('cascade'); + + $table->unique(['article_id', 'category_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('article_category'); + } +}; diff --git a/code/tests/Athenia/Feature/Http/Article/ArticleCreateTest.php b/code/tests/Athenia/Feature/Http/Article/ArticleCreateTest.php index d9924879..df877a0e 100644 --- a/code/tests/Athenia/Feature/Http/Article/ArticleCreateTest.php +++ b/code/tests/Athenia/Feature/Http/Article/ArticleCreateTest.php @@ -3,7 +3,9 @@ namespace Tests\Athenia\Feature\Http\Article; +use App\Models\Category; use App\Models\Role; +use App\Models\Wiki\Article; use Tests\DatabaseSetupTrait; use Tests\TestCase; use Tests\Traits\MocksApplicationLog; @@ -63,6 +65,64 @@ public function testCreateSuccessful(): void ]); } + public function testCreateSuccessfulWithCategories(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $category1 = Category::factory()->create(); + $category2 = Category::factory()->create(); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => [ + ['category_id' => $category1->id, 'relevance' => 0.8], + ['category_id' => $category2->id, 'relevance' => 0.5], + ], + ]); + + $response->assertStatus(201); + + $response->assertJson([ + 'title' => 'An Article', + 'created_by_id' => $this->actingAs->id, + ]); + + /** @var Article $article */ + $article = Article::find($response->json('id')); + + $this->assertCount(2, $article->categories); + + $category1Pivot = $article->categories->find($category1->id)->pivot; + $this->assertEquals(0.8, $category1Pivot->relevance); + + $category2Pivot = $article->categories->find($category2->id)->pivot; + $this->assertEquals(0.5, $category2Pivot->relevance); + } + + public function testCreateSuccessfulWithCategoriesDefaultRelevance(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $category = Category::factory()->create(); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => [ + ['category_id' => $category->id], + ], + ]); + + $response->assertStatus(201); + + /** @var Article $article */ + $article = Article::find($response->json('id')); + + $this->assertCount(1, $article->categories); + + $categoryPivot = $article->categories->find($category->id)->pivot; + $this->assertEquals(1.0, $categoryPivot->relevance); + } + public function testCreateFailsRequiredFieldsNotPresent(): void { $this->actAs(Role::ARTICLE_EDITOR); @@ -111,4 +171,82 @@ public function testCreateFailsStringsTooLong(): void ] ]); } + + public function testCreateFailsInvalidArrayFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => 'not-an-array', + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories' => ['The categories must be an array.'], + ] + ]); + } + + public function testCreateFailsInvalidIntegerFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => [ + ['category_id' => 'not-an-integer'], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.category_id' => ['The categories.0.category_id must be an integer.'], + ] + ]); + } + + public function testCreateFailsInvalidNumericFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => [ + ['category_id' => 1, 'relevance' => 'not-numeric'], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.relevance' => ['The categories.0.relevance must be a number.'], + ] + ]); + } + + public function testCreateFailsInvalidModelFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $response = $this->json('POST', $this->path, [ + 'title' => 'An Article', + 'categories' => [ + ['category_id' => 99999], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.category_id' => ['The selected categories.0.category_id is invalid.'], + ] + ]); + } } \ No newline at end of file diff --git a/code/tests/Athenia/Feature/Http/Article/ArticleUpdateTest.php b/code/tests/Athenia/Feature/Http/Article/ArticleUpdateTest.php index 1598d0d2..5fe7fd1e 100644 --- a/code/tests/Athenia/Feature/Http/Article/ArticleUpdateTest.php +++ b/code/tests/Athenia/Feature/Http/Article/ArticleUpdateTest.php @@ -3,6 +3,7 @@ namespace Tests\Athenia\Feature\Http\Article; +use App\Models\Category; use App\Models\Role; use App\Models\Wiki\Article; use Tests\DatabaseSetupTrait; @@ -85,6 +86,79 @@ public function testUpdateSuccessful(): void $this->assertEquals('A different title', $updated->title); } + public function testUpdateSuccessfulWithCategories(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $category1 = Category::factory()->create(); + $category2 = Category::factory()->create(); + $category3 = Category::factory()->create(); + + // Attach initial categories + $article->categories()->attach([ + $category1->id => ['relevance' => 0.9], + $category2->id => ['relevance' => 0.7], + ]); + + // Update to different categories + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => [ + ['category_id' => $category2->id, 'relevance' => 0.6], + ['category_id' => $category3->id, 'relevance' => 0.4], + ], + ]); + + $response->assertStatus(200); + + /** @var Article $updated */ + $updated = Article::find($article->id); + + // Should have 2 categories now (category1 removed, category3 added) + $this->assertCount(2, $updated->categories); + + // category1 should be removed + $this->assertNull($updated->categories->find($category1->id)); + + // category2 should have updated relevance + $category2Pivot = $updated->categories->find($category2->id)->pivot; + $this->assertEquals(0.6, $category2Pivot->relevance); + + // category3 should be newly added + $category3Pivot = $updated->categories->find($category3->id)->pivot; + $this->assertEquals(0.4, $category3Pivot->relevance); + } + + public function testUpdateSuccessfulWithCategoriesDefaultRelevance(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $category = Category::factory()->create(); + + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => [ + ['category_id' => $category->id], + ], + ]); + + $response->assertStatus(200); + + /** @var Article $updated */ + $updated = Article::find($article->id); + + $this->assertCount(1, $updated->categories); + + $categoryPivot = $updated->categories->find($category->id)->pivot; + $this->assertEquals(1.0, $categoryPivot->relevance); + } + public function testUpdateBlockedUserHasNotCreatedArticle(): void { $this->actAs(Role::ARTICLE_EDITOR); @@ -136,4 +210,94 @@ public function testCreateFailsStringsTooLong(): void ] ]); } + + public function testUpdateFailsInvalidArrayFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => 'not-an-array', + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories' => ['The categories must be an array.'], + ] + ]); + } + + public function testUpdateFailsInvalidIntegerFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => [ + ['category_id' => 'not-an-integer'], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.category_id' => ['The categories.0.category_id must be an integer.'], + ] + ]); + } + + public function testUpdateFailsInvalidNumericFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => [ + ['category_id' => 1, 'relevance' => 'not-numeric'], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.relevance' => ['The categories.0.relevance must be a number.'], + ] + ]); + } + + public function testUpdateFailsInvalidModelFields(): void + { + $this->actAs(Role::ARTICLE_EDITOR); + + $article = Article::factory()->create([ + 'created_by_id' => $this->actingAs->id, + ]); + + $response = $this->json('PUT', $this->path . '/' . $article->id, [ + 'categories' => [ + ['category_id' => 99999], + ], + ]); + + $response->assertStatus(400); + + $response->assertJson([ + 'errors' => [ + 'categories.0.category_id' => ['The selected categories.0.category_id is invalid.'], + ] + ]); + } }