Release v1.3.0 #100

Merged
myrmidex merged 20 commits from release/v1.3.0 into main 2026-03-18 20:30:29 +01:00
15 changed files with 100 additions and 356 deletions
Showing only changes of commit f3406b1713 - Show all commits

View file

@ -40,40 +40,6 @@ public function index(Request $request): JsonResponse
]); ]);
} }
/**
* Approve an article
*/
public function approve(Article $article): JsonResponse
{
try {
$article->approve('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article approved and queued for publishing.'
);
} catch (Exception $e) {
return $this->sendError('Failed to approve article: '.$e->getMessage(), [], 500);
}
}
/**
* Reject an article
*/
public function reject(Article $article): JsonResponse
{
try {
$article->reject('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article rejected.'
);
} catch (Exception $e) {
return $this->sendError('Failed to reject article: '.$e->getMessage(), [], 500);
}
}
/** /**
* Manually refresh articles from all active feeds * Manually refresh articles from all active feeds
*/ */

View file

@ -21,8 +21,6 @@ public function toArray(Request $request): array
'url' => $this->url, 'url' => $this->url,
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'is_valid' => $this->is_valid,
'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status, 'publish_status' => $this->publish_status,
'validated_at' => $this->validated_at?->toISOString(), 'validated_at' => $this->validated_at?->toISOString(),
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,

View file

@ -32,11 +32,6 @@ public function handle(ArticleApproved $event): void
return; return;
} }
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']); $article->update(['publish_status' => 'publishing']);
try { try {

View file

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use Database\Factories\ArticleFactory; use Database\Factories\ArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -23,14 +22,11 @@
* @property string $url * @property string $url
* @property string $title * @property string $title
* @property string|null $description * @property string|null $description
* @property string $approval_status
* @property string $publish_status * @property string $publish_status
* @property bool|null $is_valid
* @property Carbon|null $validated_at * @property Carbon|null $validated_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property ArticlePublication|null $articlePublication * @property ArticlePublication|null $articlePublication
* @property \Illuminate\Support\HigherOrderCollectionProxy|mixed $routeArticles
*/ */
class Article extends Model class Article extends Model
{ {
@ -46,7 +42,6 @@ class Article extends Model
'image_url', 'image_url',
'published_at', 'published_at',
'author', 'author',
'approval_status',
'validated_at', 'validated_at',
'publish_status', 'publish_status',
]; ];
@ -57,7 +52,6 @@ class Article extends Model
public function casts(): array public function casts(): array
{ {
return [ return [
'approval_status' => 'string',
'publish_status' => 'string', 'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime', 'validated_at' => 'datetime',
@ -66,58 +60,6 @@ public function casts(): array
]; ];
} }
public function isValid(): bool
{
return $this->validated_at !== null && ! $this->isRejected();
}
public function isApproved(): bool
{
return $this->approval_status === 'approved';
}
public function isPending(): bool
{
return $this->approval_status === 'pending';
}
public function isRejected(): bool
{
return $this->approval_status === 'rejected';
}
public function approve(?string $approvedBy = null): void
{
$this->update([
'approval_status' => 'approved',
]);
// Fire event to trigger publishing
event(new ArticleApproved($this));
}
public function reject(?string $rejectedBy = null): void
{
$this->update([
'approval_status' => 'rejected',
]);
}
public function canBePublished(): bool
{
if (! $this->isValid()) {
return false;
}
// If approval system is disabled, auto-approve valid articles
if (! \App\Models\Setting::isPublishingApprovalsEnabled()) {
return true;
}
// If approval system is enabled, only approved articles can be published
return $this->isApproved();
}
public function getIsPublishedAttribute(): bool public function getIsPublishedAttribute(): bool
{ {
return $this->articlePublication()->exists(); return $this->articlePublication()->exists();

View file

@ -73,10 +73,6 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
*/ */
public function publishToRoutedChannels(Article $article, array $extractedData): Collection public function publishToRoutedChannels(Article $article, array $extractedData): Collection
{ {
if (! $article->isValid()) {
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE'));
}
$feed = $article->feed; $feed = $article->feed;
$activeRoutes = Route::where('feed_id', $feed->id) $activeRoutes = Route::where('feed_id', $feed->id)

View file

@ -25,7 +25,6 @@ public function definition(): array
'image_url' => $this->faker->optional()->imageUrl(), 'image_url' => $this->faker->optional()->imageUrl(),
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(), 'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished', 'publish_status' => 'unpublished',
]; ];
} }

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Migrate existing article approval_status to route_articles
$validatedArticles = DB::table('articles')
->whereIn('approval_status', ['approved', 'rejected'])
->whereNotNull('validated_at')
->get();
foreach ($validatedArticles as $article) {
$routes = DB::table('routes')
->where('feed_id', $article->feed_id)
->where('is_active', true)
->get();
foreach ($routes as $route) {
$exists = DB::table('route_articles')
->where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->where('article_id', $article->id)
->exists();
if ($exists) {
continue;
}
DB::table('route_articles')->insert([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
'approval_status' => $article->approval_status,
'validated_at' => $article->validated_at,
'created_at' => $article->created_at,
'updated_at' => now(),
]);
}
}
// Remove approval_status column from articles
Schema::table('articles', function (Blueprint $table) {
$table->dropIndex(['published_at', 'approval_status']);
$table->dropColumn('approval_status');
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending')->after('feed_id');
$table->index(['published_at', 'approval_status']);
});
}
};

View file

@ -47,8 +47,6 @@
// Articles // Articles
Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index'); Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index');
Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve');
Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject');
Route::post('/articles/refresh', [ArticlesController::class, 'refresh'])->name('api.articles.refresh'); Route::post('/articles/refresh', [ArticlesController::class, 'refresh'])->name('api.articles.refresh');
// Platform Accounts // Platform Accounts

View file

@ -134,14 +134,12 @@ public function test_article_model_creates_successfully(): void
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'title' => 'Test Article', 'title' => 'Test Article',
'url' => 'https://example.com/article', 'url' => 'https://example.com/article',
'approval_status' => 'pending',
]); ]);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'title' => 'Test Article', 'title' => 'Test Article',
'url' => 'https://example.com/article', 'url' => 'https://example.com/article',
'approval_status' => 'pending',
]); ]);
$this->assertEquals($feed->id, $article->feed->id); $this->assertEquals($feed->id, $article->feed->id);

View file

@ -102,60 +102,6 @@ public function test_index_orders_articles_by_created_at_desc(): void
$this->assertEquals('First Article', $articles[1]['title']); $this->assertEquals('First Article', $articles[1]['title']);
} }
public function test_approve_article_successfully(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$response = $this->postJson("/api/v1/articles/{$article->id}/approve");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Article approved and queued for publishing.',
]);
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_approve_nonexistent_article_returns_404(): void
{
$response = $this->postJson('/api/v1/articles/999/approve');
$response->assertStatus(404);
}
public function test_reject_article_successfully(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$response = $this->postJson("/api/v1/articles/{$article->id}/reject");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Article rejected.',
]);
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_reject_nonexistent_article_returns_404(): void
{
$response = $this->postJson('/api/v1/articles/999/reject');
$response->assertStatus(404);
}
public function test_index_includes_settings(): void public function test_index_includes_settings(): void
{ {
$response = $this->getJson('/api/v1/articles'); $response = $this->getJson('/api/v1/articles');

View file

@ -188,7 +188,6 @@ public function test_validate_article_listener_processes_new_article(): void
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending',
]); ]);
// Mock ArticleFetcher to return valid article data // Mock ArticleFetcher to return valid article data

View file

@ -27,7 +27,7 @@ public function test_exception_during_publishing_creates_error_notification(): v
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article', 'title' => 'Test Article',
]); ]);
@ -58,7 +58,7 @@ public function test_no_publications_created_creates_warning_notification(): voi
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article', 'title' => 'Test Article',
]); ]);
@ -93,7 +93,7 @@ public function test_successful_publish_does_not_create_notification(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article', 'title' => 'Test Article',
]); ]);

View file

@ -54,7 +54,6 @@ public function test_listener_validates_article_and_creates_route_articles(): vo
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending',
]); ]);
$listener = $this->createListenerWithMockedFetcher('Article about Belgium'); $listener = $this->createListenerWithMockedFetcher('Article about Belgium');
@ -91,7 +90,6 @@ public function test_listener_skips_articles_with_existing_publication(): void
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending',
]); ]);
ArticlePublication::create([ ArticlePublication::create([
@ -120,7 +118,6 @@ public function test_listener_handles_validation_errors_gracefully(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending',
]); ]);
$listener->handle(new NewArticleFetched($article)); $listener->handle(new NewArticleFetched($article));

View file

@ -2,11 +2,9 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -20,169 +18,9 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Mock HTTP requests to prevent external calls
Http::fake([ Http::fake([
'*' => Http::response('', 500), '*' => Http::response('', 500),
]); ]);
// Don't fake events globally - let individual tests control this
}
public function test_is_valid_returns_false_when_approval_status_is_pending(): void
{
$article = Article::factory()->make([
'approval_status' => 'pending',
]);
$this->assertFalse($article->isValid());
}
public function test_is_valid_returns_false_when_approval_status_is_rejected(): void
{
$article = Article::factory()->make([
'approval_status' => 'rejected',
]);
$this->assertFalse($article->isValid());
}
public function test_is_valid_returns_true_when_validated_and_not_rejected(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertTrue($article->isValid());
}
public function test_is_valid_returns_false_when_not_validated(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => null,
]);
$this->assertFalse($article->isValid());
}
public function test_is_approved_returns_true_for_approved_status(): void
{
$article = Article::factory()->make(['approval_status' => 'approved']);
$this->assertTrue($article->isApproved());
}
public function test_is_approved_returns_false_for_non_approved_status(): void
{
$article = Article::factory()->make(['approval_status' => 'pending']);
$this->assertFalse($article->isApproved());
}
public function test_is_pending_returns_true_for_pending_status(): void
{
$article = Article::factory()->make(['approval_status' => 'pending']);
$this->assertTrue($article->isPending());
}
public function test_is_rejected_returns_true_for_rejected_status(): void
{
$article = Article::factory()->make(['approval_status' => 'rejected']);
$this->assertTrue($article->isRejected());
}
public function test_approve_updates_status_and_triggers_event(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
Event::fake();
$article->approve('test_user');
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
Event::assertDispatched(ArticleApproved::class, function ($event) use ($article) {
return $event->article->id === $article->id;
});
}
public function test_approve_without_approved_by_parameter(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
Event::fake();
$article->approve();
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_reject_updates_status(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$article->reject('test_user');
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_can_be_published_returns_false_for_invalid_article(): void
{
$article = Article::factory()->make([
'approval_status' => 'rejected', // rejected = not valid
]);
$this->assertFalse($article->canBePublished());
}
public function test_can_be_published_requires_approval_when_approvals_enabled(): void
{
// Create a setting that enables approvals
Setting::create(['key' => 'enable_publishing_approvals', 'value' => '1']);
$pendingArticle = Article::factory()->make([
'approval_status' => 'pending',
'validated_at' => now(),
]);
$approvedArticle = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertFalse($pendingArticle->canBePublished());
$this->assertTrue($approvedArticle->canBePublished());
}
public function test_can_be_published_returns_true_when_approvals_disabled(): void
{
// Make sure approvals are disabled (default behavior)
Setting::where('key', 'enable_publishing_approvals')->delete();
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertTrue($article->canBePublished());
} }
public function test_feed_relationship(): void public function test_feed_relationship(): void
@ -194,6 +32,14 @@ public function test_feed_relationship(): void
$this->assertEquals($feed->id, $article->feed->id); $this->assertEquals($feed->id, $article->feed->id);
} }
public function test_route_articles_relationship(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$this->assertCount(0, $article->routeArticles);
}
public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void
{ {
Event::fake([NewArticleFetched::class]); Event::fake([NewArticleFetched::class]);
@ -207,4 +53,23 @@ public function test_dispatch_fetched_event_fires_new_article_fetched_event(): v
return $event->article->id === $article->id; return $event->article->id === $article->id;
}); });
} }
public function test_is_published_attribute(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$this->assertFalse($article->is_published);
}
public function test_validated_at_is_cast_to_datetime(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'validated_at' => now(),
]);
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $article->validated_at);
}
} }

View file

@ -3,7 +3,6 @@
namespace Tests\Unit\Services\Publishing; namespace Tests\Unit\Services\Publishing;
use App\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use App\Exceptions\PublishException;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
@ -45,23 +44,12 @@ protected function tearDown(): void
parent::tearDown(); parent::tearDown();
} }
public function test_publish_to_routed_channels_throws_exception_for_invalid_article(): void
{
$article = Article::factory()->create(['approval_status' => 'rejected']);
$extractedData = ['title' => 'Test Title'];
$this->expectException(PublishException::class);
$this->expectExceptionMessage('CANNOT_PUBLISH_INVALID_ARTICLE');
$this->service->publishToRoutedChannels($article, $extractedData);
}
public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void
{ {
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(), 'validated_at' => now(),
]); ]);
$extractedData = ['title' => 'Test Title']; $extractedData = ['title' => 'Test Title'];
@ -78,7 +66,7 @@ public function test_publish_to_routed_channels_skips_routes_without_active_acco
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(), 'validated_at' => now(),
]); ]);
@ -106,8 +94,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
{ {
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]);
'validated_at' => now()]);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -153,8 +140,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
{ {
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]);
'validated_at' => now()]);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -195,8 +181,7 @@ public function test_publish_to_routed_channels_publishes_to_multiple_routes():
{ {
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]);
'validated_at' => now()]);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -251,8 +236,7 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
{ {
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]);
'validated_at' => now()]);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -309,7 +293,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(), 'validated_at' => now(),
'url' => 'https://example.com/article-1', 'url' => 'https://example.com/article-1',
]); ]);
@ -361,7 +345,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(), 'validated_at' => now(),
'url' => 'https://example.com/article-new-url', 'url' => 'https://example.com/article-new-url',
'title' => 'Breaking News: Something Happened', 'title' => 'Breaking News: Something Happened',
@ -414,7 +398,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(), 'validated_at' => now(),
'url' => 'https://example.com/unique-article', 'url' => 'https://example.com/unique-article',
]); ]);