Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions code/app/Athenia/Models/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions code/app/Athenia/Models/Wiki/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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 => [
Expand Down
53 changes: 53 additions & 0 deletions code/app/Athenia/Repositories/Wiki/ArticleRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +18,7 @@
class ArticleRepository extends BaseRepositoryAbstract implements ArticleRepositoryContract
{
use \App\Athenia\Repositories\Traits\NotImplemented\Delete;
use CanGetAndUnset;

/**
* ArticleRepository constructor.
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('article_category', function (Blueprint $table) {
$table->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');
}
};
138 changes: 138 additions & 0 deletions code/tests/Athenia/Feature/Http/Article/ArticleCreateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.'],
]
]);
}
}
Loading