From b832d6d8500c01e9d5064fc9aaac87be8d577fb0 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:24:03 +0100 Subject: [PATCH] 85 - Add route_articles table, model, and factory for per-route approval --- app/Models/Article.php | 9 ++ app/Models/Route.php | 9 ++ app/Models/RouteArticle.php | 95 ++++++++++++ database/factories/RouteArticleFactory.php | 82 ++++++++++ ..._01_000007_create_route_articles_table.php | 34 +++++ tests/Unit/Models/RouteArticleTest.php | 140 ++++++++++++++++++ 6 files changed, 369 insertions(+) create mode 100644 app/Models/RouteArticle.php create mode 100644 database/factories/RouteArticleFactory.php create mode 100644 database/migrations/2024_01_01_000007_create_route_articles_table.php create mode 100644 tests/Unit/Models/RouteArticleTest.php diff --git a/app/Models/Article.php b/app/Models/Article.php index 59477d9..96b3f74 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; @@ -137,6 +138,14 @@ public function feed(): BelongsTo return $this->belongsTo(Feed::class); } + /** + * @return HasMany + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class); + } + public function dispatchFetchedEvent(): void { event(new NewArticleFetched($this)); diff --git a/app/Models/Route.php b/app/Models/Route.php index 1125bf5..c78016c 100644 --- a/app/Models/Route.php +++ b/app/Models/Route.php @@ -64,4 +64,13 @@ public function keywords(): HasMany return $this->hasMany(Keyword::class, 'feed_id', 'feed_id') ->where('platform_channel_id', $this->platform_channel_id); } + + /** + * @return HasMany + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } } diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php new file mode 100644 index 0000000..52106df --- /dev/null +++ b/app/Models/RouteArticle.php @@ -0,0 +1,95 @@ + */ + use HasFactory; + + protected $fillable = [ + 'feed_id', + 'platform_channel_id', + 'article_id', + 'approval_status', + 'validated_at', + ]; + + protected $casts = [ + 'validated_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function route(): BelongsTo + { + return $this->belongsTo(Route::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } + + /** + * @return BelongsTo + */ + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } + + /** + * @return BelongsTo + */ + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } + + /** + * @return BelongsTo + */ + public function platformChannel(): BelongsTo + { + return $this->belongsTo(PlatformChannel::class); + } + + public function isPending(): bool + { + return $this->approval_status === 'pending'; + } + + public function isApproved(): bool + { + return $this->approval_status === 'approved'; + } + + public function isRejected(): bool + { + return $this->approval_status === 'rejected'; + } + + public function approve(): void + { + $this->update(['approval_status' => 'approved']); + } + + public function reject(): void + { + $this->update(['approval_status' => 'rejected']); + } +} diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php new file mode 100644 index 0000000..1b11673 --- /dev/null +++ b/database/factories/RouteArticleFactory.php @@ -0,0 +1,82 @@ + Feed::factory(), + 'platform_channel_id' => PlatformChannel::factory(), + 'article_id' => Article::factory(), + 'approval_status' => 'pending', + 'validated_at' => null, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (RouteArticle $routeArticle) { + // Ensure a route exists for this feed+channel combination + Route::firstOrCreate( + [ + 'feed_id' => $routeArticle->feed_id, + 'platform_channel_id' => $routeArticle->platform_channel_id, + ], + [ + 'is_active' => true, + 'priority' => 50, + ] + ); + + // Ensure the article belongs to the same feed + if ($routeArticle->article_id) { + $article = Article::find($routeArticle->article_id); + if ($article && $article->feed_id !== $routeArticle->feed_id) { + $article->update(['feed_id' => $routeArticle->feed_id]); + } + } + }); + } + + public function forRoute(Route $route): static + { + return $this->state(fn (array $attributes) => [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'pending', + ]); + } + + public function approved(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'approved', + 'validated_at' => now(), + ]); + } + + public function rejected(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'rejected', + 'validated_at' => now(), + ]); + } +} diff --git a/database/migrations/2024_01_01_000007_create_route_articles_table.php b/database/migrations/2024_01_01_000007_create_route_articles_table.php new file mode 100644 index 0000000..d32a0ad --- /dev/null +++ b/database/migrations/2024_01_01_000007_create_route_articles_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('feed_id'); + $table->unsignedBigInteger('platform_channel_id'); + $table->foreignId('article_id')->constrained()->onDelete('cascade'); + $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->timestamp('validated_at')->nullable(); + $table->timestamps(); + + $table->foreign(['feed_id', 'platform_channel_id']) + ->references(['feed_id', 'platform_channel_id']) + ->on('routes') + ->onDelete('cascade'); + + $table->unique(['feed_id', 'platform_channel_id', 'article_id'], 'route_articles_unique'); + $table->index(['approval_status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('route_articles'); + } +}; diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php new file mode 100644 index 0000000..89e2471 --- /dev/null +++ b/tests/Unit/Models/RouteArticleTest.php @@ -0,0 +1,140 @@ +create(); + + $this->assertInstanceOf(Article::class, $routeArticle->article); + } + + public function test_route_article_belongs_to_feed(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(Feed::class, $routeArticle->feed); + } + + public function test_route_article_belongs_to_platform_channel(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(PlatformChannel::class, $routeArticle->platformChannel); + } + + public function test_route_article_has_default_pending_status(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertEquals('pending', $routeArticle->approval_status); + $this->assertTrue($routeArticle->isPending()); + $this->assertFalse($routeArticle->isApproved()); + $this->assertFalse($routeArticle->isRejected()); + } + + public function test_route_article_can_be_approved(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->approve(); + + $this->assertEquals('approved', $routeArticle->fresh()->approval_status); + } + + public function test_route_article_can_be_rejected(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->reject(); + + $this->assertEquals('rejected', $routeArticle->fresh()->approval_status); + } + + public function test_article_has_many_route_articles(): void + { + $route1 = Route::factory()->active()->create(); + $route2 = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route1->feed_id]); + + RouteArticle::factory()->forRoute($route1)->create(['article_id' => $article->id]); + RouteArticle::factory()->forRoute($route2)->create(['article_id' => $article->id]); + + $this->assertCount(2, $article->routeArticles); + } + + public function test_route_has_many_route_articles(): void + { + $route = Route::factory()->active()->create(); + $article1 = Article::factory()->create(['feed_id' => $route->feed_id]); + $article2 = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article1->id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article2->id]); + + $this->assertCount(2, $route->routeArticles); + } + + public function test_unique_constraint_prevents_duplicate_route_articles(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $this->expectException(\Illuminate\Database\QueryException::class); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + } + + public function test_route_article_cascade_deletes_when_article_deleted(): void + { + $routeArticle = RouteArticle::factory()->create(); + $articleId = $routeArticle->article_id; + + Article::destroy($articleId); + + $this->assertDatabaseMissing('route_articles', ['article_id' => $articleId]); + } + + public function test_route_article_cascade_deletes_when_route_deleted(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->delete(); + + $this->assertDatabaseMissing('route_articles', [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function test_route_article_belongs_to_route(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + $routeArticle = RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $loadedRoute = $routeArticle->route; + + $this->assertInstanceOf(Route::class, $loadedRoute); + $this->assertEquals($route->feed_id, $loadedRoute->feed_id); + $this->assertEquals($route->platform_channel_id, $loadedRoute->platform_channel_id); + } +}