Add keywords to front-end
This commit is contained in:
parent
54abf52e20
commit
a4b5aee790
17 changed files with 1050 additions and 127 deletions
143
backend/app/Http/Controllers/Api/V1/KeywordsController.php
Normal file
143
backend/app/Http/Controllers/Api/V1/KeywordsController.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class KeywordsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Display keywords for a specific route
|
||||
*/
|
||||
public function index(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
$keywords = Keyword::where('feed_id', $feed->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->orderBy('keyword')
|
||||
->get();
|
||||
|
||||
return $this->sendResponse(
|
||||
$keywords->toArray(),
|
||||
'Keywords retrieved successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new keyword for a route
|
||||
*/
|
||||
public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['feed_id'] = $feed->id;
|
||||
$validated['platform_channel_id'] = $channel->id;
|
||||
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||
|
||||
// Check if keyword already exists for this route
|
||||
$existingKeyword = Keyword::where('feed_id', $feed->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->where('keyword', $validated['keyword'])
|
||||
->first();
|
||||
|
||||
if ($existingKeyword) {
|
||||
return $this->sendError('Keyword already exists for this route.', [], 409);
|
||||
}
|
||||
|
||||
$keyword = Keyword::create($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->toArray(),
|
||||
'Keyword created successfully!',
|
||||
201
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a keyword's status
|
||||
*/
|
||||
public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$keyword->update($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->fresh()->toArray(),
|
||||
'Keyword updated successfully!'
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a keyword from a route
|
||||
*/
|
||||
public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$keyword->delete();
|
||||
|
||||
return $this->sendResponse(
|
||||
null,
|
||||
'Keyword deleted successfully!'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle keyword active status
|
||||
*/
|
||||
public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$newStatus = !$keyword->is_active;
|
||||
$keyword->update(['is_active' => $newStatus]);
|
||||
|
||||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->fresh()->toArray(),
|
||||
"Keyword {$status} successfully!"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +269,6 @@ public function createRoute(Request $request): JsonResponse
|
|||
'feed_id' => 'required|exists:feeds,id',
|
||||
'platform_channel_id' => 'required|exists:platform_channels,id',
|
||||
'priority' => 'nullable|integer|min:1|max:100',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -282,7 +281,6 @@ public function createRoute(Request $request): JsonResponse
|
|||
'feed_id' => $validated['feed_id'],
|
||||
'platform_channel_id' => $validated['platform_channel_id'],
|
||||
'priority' => $validated['priority'] ?? 50,
|
||||
'filters' => $validated['filters'] ?? [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class RoutingController extends BaseController
|
|||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$routes = Route::with(['feed', 'platformChannel'])
|
||||
$routes = Route::with(['feed', 'platformChannel', 'keywords'])
|
||||
->orderBy('is_active', 'desc')
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
|
|
@ -47,7 +47,7 @@ public function store(Request $request): JsonResponse
|
|||
$route = Route::create($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->load(['feed', 'platformChannel'])),
|
||||
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
|
||||
'Routing configuration created successfully!',
|
||||
201
|
||||
);
|
||||
|
|
@ -69,7 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
$route->load(['feed', 'platformChannel']);
|
||||
$route->load(['feed', 'platformChannel', 'keywords']);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route),
|
||||
|
|
@ -99,7 +99,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
|
|||
->update($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel'])),
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||
'Routing configuration updated successfully!'
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
|
|
@ -154,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel'])),
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||
"Routing configuration {$status} successfully!"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ public function toArray(Request $request): array
|
|||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'feed' => new FeedResource($this->whenLoaded('feed')),
|
||||
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')),
|
||||
'keywords' => $this->whenLoaded('keywords', function () {
|
||||
return $this->keywords->map(function ($keyword) {
|
||||
return [
|
||||
'id' => $keyword->id,
|
||||
'keyword' => $keyword->keyword,
|
||||
'is_active' => $keyword->is_active,
|
||||
];
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ public function getStatusAttribute(): string
|
|||
public function channels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PlatformChannel::class, 'routes')
|
||||
->withPivot(['is_active', 'priority', 'filters'])
|
||||
->withPivot(['is_active', 'priority'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ public function getFullNameAttribute(): string
|
|||
public function feeds(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Feed::class, 'routes')
|
||||
->withPivot(['is_active', 'priority', 'filters'])
|
||||
->withPivot(['is_active', 'priority'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
* @property int $platform_channel_id
|
||||
* @property bool $is_active
|
||||
* @property int $priority
|
||||
* @property array<string, mixed> $filters
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
|
|
@ -33,13 +32,11 @@ class Route extends Model
|
|||
'feed_id',
|
||||
'platform_channel_id',
|
||||
'is_active',
|
||||
'priority',
|
||||
'filters'
|
||||
'priority'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'filters' => 'array'
|
||||
'is_active' => 'boolean'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
|
|
@ -36,15 +37,26 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
|
|||
|
||||
$feed = $article->feed;
|
||||
|
||||
/** @var EloquentCollection<int, PlatformChannel> $activeChannels */
|
||||
$activeChannels = $feed->activeChannels()->with(['platformInstance', 'activePlatformAccounts'])->get();
|
||||
// Get active routes with keywords instead of just channels
|
||||
$activeRoutes = Route::where('feed_id', $feed->id)
|
||||
->where('is_active', true)
|
||||
->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords'])
|
||||
->orderBy('priority', 'desc')
|
||||
->get();
|
||||
|
||||
return $activeChannels->map(function (PlatformChannel $channel) use ($article, $extractedData) {
|
||||
// Filter routes based on keyword matches
|
||||
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) {
|
||||
return $this->routeMatchesArticle($route, $extractedData);
|
||||
});
|
||||
|
||||
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
|
||||
$channel = $route->platformChannel;
|
||||
$account = $channel->activePlatformAccounts()->first();
|
||||
|
||||
if (! $account) {
|
||||
LogSaver::warning('No active account for channel', $channel, [
|
||||
'article_id' => $article->id
|
||||
'article_id' => $article->id,
|
||||
'route_priority' => $route->priority
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
|
@ -55,6 +67,43 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
|
|||
->filter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches an article based on keywords
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function routeMatchesArticle(Route $route, array $extractedData): bool
|
||||
{
|
||||
// Get active keywords for this route
|
||||
$activeKeywords = $route->keywords->where('is_active', true);
|
||||
|
||||
// If no keywords are defined for this route, the route matches any article
|
||||
if ($activeKeywords->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get article content for keyword matching
|
||||
$articleContent = '';
|
||||
if (isset($extractedData['full_article'])) {
|
||||
$articleContent = $extractedData['full_article'];
|
||||
}
|
||||
if (isset($extractedData['title'])) {
|
||||
$articleContent .= ' ' . $extractedData['title'];
|
||||
}
|
||||
if (isset($extractedData['description'])) {
|
||||
$articleContent .= ' ' . $extractedData['description'];
|
||||
}
|
||||
|
||||
// Check if any of the route's keywords match the article content
|
||||
foreach ($activeKeywords as $keywordModel) {
|
||||
$keyword = $keywordModel->keyword;
|
||||
if (stripos($articleContent, $keyword) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
|
|
@ -74,10 +123,8 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
|
|||
'publication_data' => $postData,
|
||||
]);
|
||||
|
||||
LogSaver::info('Published to channel via routing', $channel, [
|
||||
'article_id' => $article->id,
|
||||
// Use nullsafe operator in case pivot is not loaded in tests
|
||||
'priority' => $channel->pivot?->priority
|
||||
LogSaver::info('Published to channel via keyword-filtered routing', $channel, [
|
||||
'article_id' => $article->id
|
||||
]);
|
||||
|
||||
return $publication;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
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::table('routes', function (Blueprint $table) {
|
||||
$table->dropColumn('filters');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('routes', function (Blueprint $table) {
|
||||
$table->json('filters')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Http\Controllers\Api\V1\PlatformAccountsController;
|
||||
use App\Http\Controllers\Api\V1\PlatformChannelsController;
|
||||
use App\Http\Controllers\Api\V1\RoutingController;
|
||||
use App\Http\Controllers\Api\V1\KeywordsController;
|
||||
use App\Http\Controllers\Api\V1\SettingsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -100,6 +101,13 @@
|
|||
Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy');
|
||||
Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle');
|
||||
|
||||
// Keywords
|
||||
Route::get('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'index'])->name('api.keywords.index');
|
||||
Route::post('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'store'])->name('api.keywords.store');
|
||||
Route::put('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'update'])->name('api.keywords.update');
|
||||
Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy');
|
||||
Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle');
|
||||
|
||||
// Settings
|
||||
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
|
||||
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class KeywordsControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected Feed $feed;
|
||||
protected PlatformChannel $channel;
|
||||
protected Route $route;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->feed = Feed::factory()->create();
|
||||
$this->channel = PlatformChannel::factory()->create();
|
||||
|
||||
$this->route = Route::create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_get_keywords_for_route(): void
|
||||
{
|
||||
$keyword = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'keyword' => 'test keyword',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'keyword',
|
||||
'is_active',
|
||||
'feed_id',
|
||||
'platform_channel_id'
|
||||
]
|
||||
]
|
||||
])
|
||||
->assertJsonPath('data.0.keyword', 'test keyword');
|
||||
}
|
||||
|
||||
public function test_can_create_keyword_for_route(): void
|
||||
{
|
||||
$keywordData = [
|
||||
'keyword' => 'new keyword',
|
||||
'is_active' => true
|
||||
];
|
||||
|
||||
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'id',
|
||||
'keyword',
|
||||
'is_active',
|
||||
'feed_id',
|
||||
'platform_channel_id'
|
||||
]
|
||||
])
|
||||
->assertJsonPath('data.keyword', 'new keyword')
|
||||
->assertJsonPath('data.is_active', true);
|
||||
|
||||
$this->assertDatabaseHas('keywords', [
|
||||
'keyword' => 'new keyword',
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'is_active' => true
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_create_duplicate_keyword_for_route(): void
|
||||
{
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'keyword' => 'duplicate keyword'
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [
|
||||
'keyword' => 'duplicate keyword'
|
||||
]);
|
||||
|
||||
$response->assertStatus(409)
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('message', 'Keyword already exists for this route.');
|
||||
}
|
||||
|
||||
public function test_can_update_keyword(): void
|
||||
{
|
||||
$keyword = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [
|
||||
'is_active' => false
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('data.is_active', false);
|
||||
|
||||
$this->assertDatabaseHas('keywords', [
|
||||
'id' => $keyword->id,
|
||||
'is_active' => false
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_delete_keyword(): void
|
||||
{
|
||||
$keyword = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseMissing('keywords', [
|
||||
'id' => $keyword->id
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_toggle_keyword(): void
|
||||
{
|
||||
$keyword = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel->id,
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('data.is_active', false);
|
||||
|
||||
$this->assertDatabaseHas('keywords', [
|
||||
'id' => $keyword->id,
|
||||
'is_active' => false
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_access_keyword_from_different_route(): void
|
||||
{
|
||||
$otherFeed = Feed::factory()->create();
|
||||
$otherChannel = PlatformChannel::factory()->create();
|
||||
|
||||
$keyword = Keyword::factory()->create([
|
||||
'feed_id' => $otherFeed->id,
|
||||
'platform_channel_id' => $otherChannel->id
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}");
|
||||
|
||||
$response->assertStatus(404)
|
||||
->assertJsonPath('message', 'Keyword not found for this route.');
|
||||
}
|
||||
|
||||
public function test_validates_required_fields(): void
|
||||
{
|
||||
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['keyword']);
|
||||
}
|
||||
}
|
||||
|
|
@ -333,7 +333,6 @@ public function test_create_route_creates_route_successfully()
|
|||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $platformChannel->id,
|
||||
'priority' => 75,
|
||||
'filters' => ['keyword' => 'test'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/onboarding/route', $routeData);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
|
|
@ -49,7 +50,7 @@ public function test_publish_to_routed_channels_throws_exception_for_invalid_art
|
|||
$this->service->publishToRoutedChannels($article, $extractedData);
|
||||
}
|
||||
|
||||
public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_channels(): void
|
||||
public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
|
|
@ -64,7 +65,7 @@ public function test_publish_to_routed_channels_returns_empty_collection_when_no
|
|||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
|
||||
public function test_publish_to_routed_channels_skips_channels_without_active_accounts(): void
|
||||
public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void
|
||||
{
|
||||
// Arrange: valid article
|
||||
$feed = Feed::factory()->create();
|
||||
|
|
@ -73,32 +74,17 @@ public function test_publish_to_routed_channels_skips_channels_without_active_ac
|
|||
'is_valid' => true,
|
||||
]);
|
||||
|
||||
// Create an active channel with no active accounts
|
||||
// Create a route with a channel but no active accounts
|
||||
$channel = PlatformChannel::factory()->create();
|
||||
$channel->load('platformInstance');
|
||||
|
||||
// Mock feed->activeChannels()->with()->get() chain to return our channel
|
||||
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$relationMock->shouldReceive('with')->andReturnSelf();
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channel]));
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
$feedMock = \Mockery::mock(Feed::class)->makePartial();
|
||||
$feedMock->setRawAttributes($feed->getAttributes());
|
||||
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock);
|
||||
|
||||
// Attach mocked feed to the article relation
|
||||
$article->setRelation('feed', $feedMock);
|
||||
|
||||
// No publisher should be constructed because there are no active accounts
|
||||
|
||||
// Also ensure channel->activePlatformAccounts() returns no accounts via relation mock
|
||||
$channelPartial = \Mockery::mock($channel)->makePartial();
|
||||
$accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation->shouldReceive('first')->andReturn(null);
|
||||
$channelPartial->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation);
|
||||
|
||||
// Replace channel in relation return with the partial mock
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelPartial]));
|
||||
// Don't create any platform accounts for the channel
|
||||
|
||||
// Act
|
||||
$result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']);
|
||||
|
|
@ -114,24 +100,23 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
|
|||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
|
||||
|
||||
$channel = PlatformChannel::factory()->create();
|
||||
$channel->load('platformInstance');
|
||||
|
||||
// Create an active account and pretend it's active for the channel via relation mock
|
||||
$platformInstance = PlatformInstance::factory()->create();
|
||||
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$account = PlatformAccount::factory()->create();
|
||||
$channelMock = \Mockery::mock($channel)->makePartial();
|
||||
$accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation->shouldReceive('first')->andReturn($account);
|
||||
$channelMock->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation);
|
||||
|
||||
// Mock feed activeChannels chain
|
||||
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$relationMock->shouldReceive('with')->andReturnSelf();
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock]));
|
||||
$feedMock = \Mockery::mock(Feed::class)->makePartial();
|
||||
$feedMock->setRawAttributes($feed->getAttributes());
|
||||
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock);
|
||||
$article->setRelation('feed', $feedMock);
|
||||
// Create route
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Attach account to channel as active
|
||||
$channel->platformAccounts()->attach($account->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Mock publisher via service seam
|
||||
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
|
||||
|
|
@ -161,22 +146,23 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
|
|||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
|
||||
|
||||
$channel = PlatformChannel::factory()->create();
|
||||
$channel->load('platformInstance');
|
||||
|
||||
$platformInstance = PlatformInstance::factory()->create();
|
||||
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$account = PlatformAccount::factory()->create();
|
||||
$channelMock = \Mockery::mock($channel)->makePartial();
|
||||
$accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation->shouldReceive('first')->andReturn($account);
|
||||
$channelMock->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation);
|
||||
|
||||
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$relationMock->shouldReceive('with')->andReturnSelf();
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock]));
|
||||
$feedMock = \Mockery::mock(Feed::class)->makePartial();
|
||||
$feedMock->setRawAttributes($feed->getAttributes());
|
||||
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock);
|
||||
$article->setRelation('feed', $feedMock);
|
||||
// Create route
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Attach account to channel as active
|
||||
$channel->platformAccounts()->attach($account->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Publisher throws an exception via service seam
|
||||
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
|
||||
|
|
@ -195,36 +181,42 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
|
|||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
public function test_publish_to_routed_channels_publishes_to_multiple_channels(): void
|
||||
public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
|
||||
|
||||
$channel1 = PlatformChannel::factory()->create();
|
||||
$channel2 = PlatformChannel::factory()->create();
|
||||
$channel1->load('platformInstance');
|
||||
$channel2->load('platformInstance');
|
||||
|
||||
$platformInstance = PlatformInstance::factory()->create();
|
||||
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$account1 = PlatformAccount::factory()->create();
|
||||
$account2 = PlatformAccount::factory()->create();
|
||||
|
||||
$channelMock1 = \Mockery::mock($channel1)->makePartial();
|
||||
$accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation1->shouldReceive('first')->andReturn($account1);
|
||||
$channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1);
|
||||
$channelMock2 = \Mockery::mock($channel2)->makePartial();
|
||||
$accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation2->shouldReceive('first')->andReturn($account2);
|
||||
$channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2);
|
||||
// Create routes
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel1->id,
|
||||
'is_active' => true,
|
||||
'priority' => 100
|
||||
]);
|
||||
|
||||
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$relationMock->shouldReceive('with')->andReturnSelf();
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2]));
|
||||
$feedMock = \Mockery::mock(Feed::class)->makePartial();
|
||||
$feedMock->setRawAttributes($feed->getAttributes());
|
||||
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock);
|
||||
$article->setRelation('feed', $feedMock);
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel2->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Attach accounts to channels as active
|
||||
$channel1->platformAccounts()->attach($account1->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
$channel2->platformAccounts()->attach($account2->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
|
||||
$publisherDouble->shouldReceive('publishToChannel')
|
||||
|
|
@ -250,30 +242,36 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
|
|||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
|
||||
|
||||
$channel1 = PlatformChannel::factory()->create();
|
||||
$channel2 = PlatformChannel::factory()->create();
|
||||
$channel1->load('platformInstance');
|
||||
$channel2->load('platformInstance');
|
||||
|
||||
$platformInstance = PlatformInstance::factory()->create();
|
||||
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
|
||||
$account1 = PlatformAccount::factory()->create();
|
||||
$account2 = PlatformAccount::factory()->create();
|
||||
|
||||
$channelMock1 = \Mockery::mock($channel1)->makePartial();
|
||||
$accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation1->shouldReceive('first')->andReturn($account1);
|
||||
$channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1);
|
||||
$channelMock2 = \Mockery::mock($channel2)->makePartial();
|
||||
$accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$accountsRelation2->shouldReceive('first')->andReturn($account2);
|
||||
$channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2);
|
||||
// Create routes
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel1->id,
|
||||
'is_active' => true,
|
||||
'priority' => 100
|
||||
]);
|
||||
|
||||
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class);
|
||||
$relationMock->shouldReceive('with')->andReturnSelf();
|
||||
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2]));
|
||||
$feedMock = \Mockery::mock(Feed::class)->makePartial();
|
||||
$feedMock->setRawAttributes($feed->getAttributes());
|
||||
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock);
|
||||
$article->setRelation('feed', $feedMock);
|
||||
Route::create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel2->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
// Attach accounts to channels as active
|
||||
$channel1->platformAccounts()->attach($account1->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
$channel2->platformAccounts()->attach($account2->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
|
||||
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
|
||||
$publisherDouble->shouldReceive('publishToChannel')
|
||||
|
|
|
|||
263
backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php
Normal file
263
backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services\Publishing;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class KeywordFilteringTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ArticlePublishingService $service;
|
||||
private Feed $feed;
|
||||
private PlatformChannel $channel1;
|
||||
private PlatformChannel $channel2;
|
||||
private Route $route1;
|
||||
private Route $route2;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = new ArticlePublishingService();
|
||||
$this->feed = Feed::factory()->create();
|
||||
$this->channel1 = PlatformChannel::factory()->create();
|
||||
$this->channel2 = PlatformChannel::factory()->create();
|
||||
|
||||
// Create routes
|
||||
$this->route1 = Route::create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'is_active' => true,
|
||||
'priority' => 100
|
||||
]);
|
||||
|
||||
$this->route2 = Route::create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel2->id,
|
||||
'is_active' => true,
|
||||
'priority' => 50
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_route_with_no_keywords_matches_all_articles(): void
|
||||
{
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
'title' => 'Some random article',
|
||||
'description' => 'This is about something',
|
||||
'full_article' => 'The content talks about various topics'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->service, [$this->route1, $extractedData]);
|
||||
|
||||
$this->assertTrue($result, 'Route with no keywords should match any article');
|
||||
}
|
||||
|
||||
public function test_route_with_keywords_matches_article_containing_keyword(): void
|
||||
{
|
||||
// Add keywords to route1
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'Belgium',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'politics',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
'title' => 'Belgium announces new policy',
|
||||
'description' => 'The government makes changes',
|
||||
'full_article' => 'The Belgian government announced today...'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->service, [$this->route1, $extractedData]);
|
||||
|
||||
$this->assertTrue($result, 'Route should match article containing keyword "Belgium"');
|
||||
}
|
||||
|
||||
public function test_route_with_keywords_does_not_match_article_without_keywords(): void
|
||||
{
|
||||
// Add keywords to route1
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'sports',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'football',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
'title' => 'Economic news update',
|
||||
'description' => 'Markets are doing well',
|
||||
'full_article' => 'The economy is showing strong growth this quarter...'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->service, [$this->route1, $extractedData]);
|
||||
|
||||
$this->assertFalse($result, 'Route should not match article without any keywords');
|
||||
}
|
||||
|
||||
public function test_inactive_keywords_are_ignored(): void
|
||||
{
|
||||
// Add active and inactive keywords to route1
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'Belgium',
|
||||
'is_active' => false // Inactive
|
||||
]);
|
||||
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'politics',
|
||||
'is_active' => true // Active
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedDataWithInactiveKeyword = [
|
||||
'title' => 'Belgium announces new policy',
|
||||
'description' => 'The government makes changes',
|
||||
'full_article' => 'The Belgian government announced today...'
|
||||
];
|
||||
|
||||
$extractedDataWithActiveKeyword = [
|
||||
'title' => 'Political changes ahead',
|
||||
'description' => 'Politics is changing',
|
||||
'full_article' => 'The political landscape is shifting...'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]);
|
||||
$result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]);
|
||||
|
||||
$this->assertFalse($result1, 'Route should not match article with inactive keyword');
|
||||
$this->assertTrue($result2, 'Route should match article with active keyword');
|
||||
}
|
||||
|
||||
public function test_keyword_matching_is_case_insensitive(): void
|
||||
{
|
||||
Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'BELGIUM',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
'title' => 'belgium news',
|
||||
'description' => 'About Belgium',
|
||||
'full_article' => 'News from belgium today...'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->service, [$this->route1, $extractedData]);
|
||||
|
||||
$this->assertTrue($result, 'Keyword matching should be case insensitive');
|
||||
}
|
||||
|
||||
public function test_keywords_match_in_title_description_and_content(): void
|
||||
{
|
||||
$keywordInTitle = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel1->id,
|
||||
'keyword' => 'title-word',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$keywordInDescription = Keyword::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'platform_channel_id' => $this->channel2->id,
|
||||
'keyword' => 'desc-word',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $this->feed->id,
|
||||
'is_valid' => true
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
'title' => 'This contains title-word',
|
||||
'description' => 'This has desc-word in it',
|
||||
'full_article' => 'The content has no special words'
|
||||
];
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->service);
|
||||
$method = $reflection->getMethod('routeMatchesArticle');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]);
|
||||
$result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]);
|
||||
|
||||
$this->assertTrue($result1, 'Should match keyword in title');
|
||||
$this->assertTrue($result2, 'Should match keyword in description');
|
||||
}
|
||||
}
|
||||
170
frontend/src/components/KeywordManager.tsx
Normal file
170
frontend/src/components/KeywordManager.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Tag } from 'lucide-react';
|
||||
import { apiClient, type Keyword, type KeywordRequest } from '../lib/api';
|
||||
|
||||
interface KeywordManagerProps {
|
||||
feedId: number;
|
||||
channelId: number;
|
||||
keywords: Keyword[];
|
||||
onKeywordChange?: () => void;
|
||||
}
|
||||
|
||||
const KeywordManager: React.FC<KeywordManagerProps> = ({
|
||||
feedId,
|
||||
channelId,
|
||||
keywords = [],
|
||||
onKeywordChange
|
||||
}) => {
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [isAddingKeyword, setIsAddingKeyword] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createKeywordMutation = useMutation({
|
||||
mutationFn: (data: KeywordRequest) => apiClient.createKeyword(feedId, channelId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
setNewKeyword('');
|
||||
setIsAddingKeyword(false);
|
||||
onKeywordChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteKeywordMutation = useMutation({
|
||||
mutationFn: (keywordId: number) => apiClient.deleteKeyword(feedId, channelId, keywordId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
onKeywordChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleKeywordMutation = useMutation({
|
||||
mutationFn: (keywordId: number) => apiClient.toggleKeyword(feedId, channelId, keywordId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
onKeywordChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddKeyword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newKeyword.trim()) {
|
||||
createKeywordMutation.mutate({ keyword: newKeyword.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKeyword = (keywordId: number) => {
|
||||
if (confirm('Are you sure you want to delete this keyword?')) {
|
||||
deleteKeywordMutation.mutate(keywordId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleKeyword = (keywordId: number) => {
|
||||
toggleKeywordMutation.mutate(keywordId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tag className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">Keywords</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAddingKeyword(true)}
|
||||
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
title="Add keyword"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAddingKeyword && (
|
||||
<form onSubmit={handleAddKeyword} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="Enter keyword..."
|
||||
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createKeywordMutation.isPending || !newKeyword.trim()}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAddingKeyword(false);
|
||||
setNewKeyword('');
|
||||
}}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{keywords.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keywords.map((keyword) => (
|
||||
<div
|
||||
key={keyword.id}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded border ${
|
||||
keyword.is_active
|
||||
? 'border-blue-200 bg-blue-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
keyword.is_active
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{keyword.keyword}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{keyword.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleKeyword(keyword.id)}
|
||||
disabled={toggleKeywordMutation.isPending}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||
title={keyword.is_active ? 'Deactivate keyword' : 'Activate keyword'}
|
||||
>
|
||||
{keyword.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteKeyword(keyword.id)}
|
||||
disabled={deleteKeywordMutation.isPending}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Delete keyword"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!isAddingKeyword && (
|
||||
<div className="text-sm text-gray-500 italic p-2 border border-gray-200 rounded">
|
||||
No keywords defined. This route will match all articles.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordManager;
|
||||
|
|
@ -190,24 +190,34 @@ export interface ChannelRequest {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
export interface Keyword {
|
||||
id: number;
|
||||
keyword: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id?: number;
|
||||
feed_id: number;
|
||||
platform_channel_id: number;
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
filters: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
feed?: Feed;
|
||||
platform_channel?: PlatformChannel;
|
||||
keywords?: Keyword[];
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
feed_id: number;
|
||||
platform_channel_id: number;
|
||||
priority?: number;
|
||||
filters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface KeywordRequest {
|
||||
keyword: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// API Client class
|
||||
|
|
@ -362,6 +372,31 @@ class ApiClient {
|
|||
return response.data.data;
|
||||
}
|
||||
|
||||
// Keywords endpoints
|
||||
async getKeywords(feedId: number, channelId: number): Promise<Keyword[]> {
|
||||
const response = await axios.get<ApiResponse<Keyword[]>>(`/routing/${feedId}/${channelId}/keywords`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async createKeyword(feedId: number, channelId: number, data: KeywordRequest): Promise<Keyword> {
|
||||
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateKeyword(feedId: number, channelId: number, keywordId: number, data: Partial<KeywordRequest>): Promise<Keyword> {
|
||||
const response = await axios.put<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async deleteKeyword(feedId: number, channelId: number, keywordId: number): Promise<void> {
|
||||
await axios.delete(`/routing/${feedId}/${channelId}/keywords/${keywordId}`);
|
||||
}
|
||||
|
||||
async toggleKeyword(feedId: number, channelId: number, keywordId: number): Promise<Keyword> {
|
||||
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}/toggle`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// Platform Channels endpoints
|
||||
async getPlatformChannels(): Promise<PlatformChannel[]> {
|
||||
const response = await axios.get<ApiResponse<PlatformChannel[]>>('/platform-channels');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel } from '../lib/api';
|
||||
import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle, Tag } from 'lucide-react';
|
||||
import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel, type Keyword } from '../lib/api';
|
||||
import KeywordManager from '../components/KeywordManager';
|
||||
|
||||
const Routes: React.FC = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
|
@ -148,6 +149,33 @@ const Routes: React.FC = () => {
|
|||
{route.platform_channel.description}
|
||||
</p>
|
||||
)}
|
||||
{route.keywords && route.keywords.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Tag className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">Keywords</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{route.keywords.map((keyword) => (
|
||||
<span
|
||||
key={keyword.id}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
keyword.is_active
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{keyword.keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!route.keywords || route.keywords.length === 0) && (
|
||||
<div className="mt-3 text-sm text-gray-400 italic">
|
||||
No keyword filters - matches all articles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
|
|
@ -352,7 +380,7 @@ const EditRouteModal: React.FC<EditRouteModalProps> = ({ route, onClose, onSubmi
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white max-h-[80vh] overflow-y-auto">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Edit Route</h3>
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-md">
|
||||
|
|
@ -379,7 +407,18 @@ const EditRouteModal: React.FC<EditRouteModalProps> = ({ route, onClose, onSubmi
|
|||
<p className="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<div className="border-t pt-4">
|
||||
<KeywordManager
|
||||
feedId={route.feed_id}
|
||||
channelId={route.platform_channel_id}
|
||||
keywords={route.keywords || []}
|
||||
onKeywordChange={() => {
|
||||
// Keywords will be refreshed via React Query invalidation
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
|
|
|||
Loading…
Reference in a new issue