From 2087ca389ebc25d0d9a303b5d9ff1fa5650fb47c Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 16 Aug 2025 10:09:38 +0200 Subject: [PATCH] Route languages --- .../Api/V1/LanguagesController.php | 62 +++ .../Api/V1/OnboardingController.php | 27 +- .../Controllers/Api/V1/RoutingController.php | 28 +- ...075443_add_language_id_to_routes_table.php | 41 ++ backend/routes/api.php | 6 + backend/src/Domains/Feed/Models/Route.php | 59 +++ .../Feed/Requests/StoreRouteRequest.php | 91 ++++ .../Feed/Requests/UpdateRouteRequest.php | 89 ++++ .../Domains/Feed/Resources/RouteResource.php | 3 + .../src/Domains/Settings/Models/Language.php | 65 +++ .../Settings/Resources/LanguageResource.php | 25 ++ .../Api/V1/RoutingControllerLanguageTest.php | 293 +++++++++++++ backend/tests/Unit/Models/LanguageTest.php | 406 +++++------------- backend/tests/Unit/Models/RouteTest.php | 217 +++++++++- .../Unit/Requests/StoreRouteRequestTest.php | 189 ++++++++ 15 files changed, 1279 insertions(+), 322 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/V1/LanguagesController.php create mode 100644 backend/database/migrations/2025_08_16_075443_add_language_id_to_routes_table.php create mode 100644 backend/src/Domains/Feed/Requests/StoreRouteRequest.php create mode 100644 backend/src/Domains/Feed/Requests/UpdateRouteRequest.php create mode 100644 backend/src/Domains/Settings/Resources/LanguageResource.php create mode 100644 backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerLanguageTest.php create mode 100644 backend/tests/Unit/Requests/StoreRouteRequestTest.php diff --git a/backend/app/Http/Controllers/Api/V1/LanguagesController.php b/backend/app/Http/Controllers/Api/V1/LanguagesController.php new file mode 100644 index 0000000..93a6f71 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/LanguagesController.php @@ -0,0 +1,62 @@ +orderBy('name') + ->get(['id', 'short_code', 'name', 'native_name']); + + return $this->sendResponse( + $languages, + 'Available languages for routes retrieved successfully.' + ); + } + + /** + * Get feeds filtered by language + */ + public function feedsByLanguage(int $languageId): JsonResponse + { + $language = Language::findOrFail($languageId); + + $feeds = $language->feeds() + ->where('feeds.is_active', true) + ->where('feed_languages.is_active', true) + ->with('languages') + ->get(['feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider']); + + return $this->sendResponse( + $feeds, + "Feeds for language '{$language->name}' retrieved successfully." + ); + } + + /** + * Get channels filtered by language + */ + public function channelsByLanguage(int $languageId): JsonResponse + { + $language = Language::findOrFail($languageId); + + $channels = $language->platformChannels() + ->where('platform_channels.is_active', true) + ->with(['platformInstance:id,name,url', 'language:id,name,short_code']) + ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']); + + return $this->sendResponse( + $channels, + "Channels for language '{$language->name}' retrieved successfully." + ); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php index bc8d29e..b4436e1 100644 --- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -91,13 +91,19 @@ public function options(): JsonResponse // Get existing feeds and channels for route creation $feeds = Feed::where('is_active', true) + ->with('languages') ->orderBy('name') ->get(['id', 'name', 'url', 'type']); $platformChannels = PlatformChannel::where('is_active', true) - ->with(['platformInstance:id,name,url']) + ->with(['platformInstance:id,name,url', 'language:id,name,short_code']) ->orderBy('name') - ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); + ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']); + + // Get languages available for routes (have both feeds and channels) + $availableLanguages = Language::availableForRoutes() + ->orderBy('name') + ->get(['id', 'short_code', 'name', 'native_name']); // Get feed providers from config $feedProviders = collect(config('feed.providers', [])) @@ -109,6 +115,7 @@ public function options(): JsonResponse 'platform_instances' => $platformInstances, 'feeds' => $feeds, 'platform_channels' => $platformChannels, + 'available_languages' => $availableLanguages, 'feed_providers' => $feedProviders, ], 'Onboarding options retrieved successfully.'); } @@ -352,6 +359,7 @@ public function createRoute(Request $request): JsonResponse $validator = Validator::make($request->all(), [ 'feed_id' => 'required|exists:feeds,id', 'platform_channel_id' => 'required|exists:platform_channels,id', + 'language_id' => 'required|exists:languages,id', 'priority' => 'nullable|integer|min:1|max:100', ]); @@ -361,9 +369,22 @@ public function createRoute(Request $request): JsonResponse $validated = $validator->validated(); + // Validate language consistency + $feed = Feed::find($validated['feed_id']); + $channel = PlatformChannel::find($validated['platform_channel_id']); + + if (!$feed || !$feed->languages()->where('languages.id', $validated['language_id'])->exists()) { + return $this->sendError('The selected feed does not support the chosen language.', [], 422); + } + + if (!$channel || $channel->language_id !== (int)$validated['language_id']) { + return $this->sendError('The selected channel does not support the chosen language.', [], 422); + } + $route = Route::create([ 'feed_id' => $validated['feed_id'], 'platform_channel_id' => $validated['platform_channel_id'], + 'language_id' => $validated['language_id'], 'priority' => $validated['priority'] ?? 50, 'is_active' => true, ]); @@ -373,7 +394,7 @@ public function createRoute(Request $request): JsonResponse ArticleDiscoveryJob::dispatch(); return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel'])), + new RouteResource($route->load(['feed', 'platformChannel', 'language'])), 'Route created successfully.' ); } diff --git a/backend/app/Http/Controllers/Api/V1/RoutingController.php b/backend/app/Http/Controllers/Api/V1/RoutingController.php index c5de3cf..02e3ef0 100644 --- a/backend/app/Http/Controllers/Api/V1/RoutingController.php +++ b/backend/app/Http/Controllers/Api/V1/RoutingController.php @@ -6,6 +6,8 @@ use Domains\Feed\Models\Feed; use Domains\Platform\Models\PlatformChannel; use Domains\Feed\Models\Route; +use Domains\Feed\Requests\StoreRouteRequest; +use Domains\Feed\Requests\UpdateRouteRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -17,7 +19,7 @@ class RoutingController extends BaseController */ public function index(): JsonResponse { - $routes = Route::with(['feed', 'platformChannel', 'keywords']) + $routes = Route::with(['feed', 'platformChannel', 'language', 'keywords']) ->orderBy('is_active', 'desc') ->orderBy('priority', 'asc') ->get(); @@ -31,15 +33,10 @@ public function index(): JsonResponse /** * Store a newly created routing configuration */ - public function store(Request $request): JsonResponse + public function store(StoreRouteRequest $request): JsonResponse { try { - $validated = $request->validate([ - 'feed_id' => 'required|exists:feeds,id', - 'platform_channel_id' => 'required|exists:platform_channels,id', - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:0', - ]); + $validated = $request->validated(); $validated['is_active'] = $validated['is_active'] ?? true; $validated['priority'] = $validated['priority'] ?? 0; @@ -47,7 +44,7 @@ public function store(Request $request): JsonResponse $route = Route::create($validated); return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->load(['feed', 'platformChannel', 'language', 'keywords'])), 'Routing configuration created successfully!', 201 ); @@ -69,7 +66,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse return $this->sendNotFound('Routing configuration not found.'); } - $route->load(['feed', 'platformChannel', 'keywords']); + $route->load(['feed', 'platformChannel', 'language', 'keywords']); return $this->sendResponse( new RouteResource($route), @@ -80,7 +77,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse /** * Update the specified routing configuration */ - public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse + public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel $channel): JsonResponse { try { $route = $this->findRoute($feed, $channel); @@ -89,17 +86,14 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel): return $this->sendNotFound('Routing configuration not found.'); } - $validated = $request->validate([ - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:0', - ]); + $validated = $request->validated(); Route::where('feed_id', $feed->id) ->where('platform_channel_id', $channel->id) ->update($validated); return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])), 'Routing configuration updated successfully!' ); } catch (ValidationException $e) { @@ -154,7 +148,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse $status = $newStatus ? 'activated' : 'deactivated'; return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])), "Routing configuration {$status} successfully!" ); } catch (\Exception $e) { diff --git a/backend/database/migrations/2025_08_16_075443_add_language_id_to_routes_table.php b/backend/database/migrations/2025_08_16_075443_add_language_id_to_routes_table.php new file mode 100644 index 0000000..88cdc27 --- /dev/null +++ b/backend/database/migrations/2025_08_16_075443_add_language_id_to_routes_table.php @@ -0,0 +1,41 @@ +foreignId('language_id')->nullable()->after('platform_channel_id')->constrained()->onDelete('cascade'); + $table->index(['language_id', 'is_active']); + }); + + // Migrate existing routes to set language based on feed's primary language and channel language (where they match) + DB::statement(" + UPDATE routes r + INNER JOIN platform_channels pc ON r.platform_channel_id = pc.id + INNER JOIN feed_languages fl ON r.feed_id = fl.feed_id AND fl.is_primary = 1 AND fl.is_active = 1 + SET r.language_id = pc.language_id + WHERE pc.language_id = fl.language_id + "); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('routes', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropIndex(['language_id', 'is_active']); + $table->dropColumn('language_id'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 15c5da0..659ba04 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\ArticlesController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\FeedsController; +use App\Http\Controllers\Api\V1\LanguagesController; use App\Http\Controllers\Api\V1\LogsController; use App\Http\Controllers\Api\V1\OnboardingController; use App\Http\Controllers\Api\V1\PlatformAccountsController; @@ -101,6 +102,11 @@ Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); + // Languages + Route::get('/languages/available-for-routes', [LanguagesController::class, 'availableForRoutes'])->name('api.languages.available-for-routes'); + Route::get('/languages/{language}/feeds', [LanguagesController::class, 'feedsByLanguage'])->name('api.languages.feeds'); + Route::get('/languages/{language}/channels', [LanguagesController::class, 'channelsByLanguage'])->name('api.languages.channels'); + // Logs Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index'); }); diff --git a/backend/src/Domains/Feed/Models/Route.php b/backend/src/Domains/Feed/Models/Route.php index 0e7adfa..84a048d 100644 --- a/backend/src/Domains/Feed/Models/Route.php +++ b/backend/src/Domains/Feed/Models/Route.php @@ -5,6 +5,7 @@ use Domains\Article\Models\Keyword; use Domains\Feed\Factories\RouteFactory; use Domains\Platform\Models\PlatformChannel; +use Domains\Settings\Models\Language; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -14,10 +15,12 @@ /** * @property int $feed_id * @property int $platform_channel_id + * @property int|null $language_id * @property bool $is_active * @property int $priority * @property Carbon $created_at * @property Carbon $updated_at + * @property Language|null $language */ class Route extends Model { @@ -38,6 +41,7 @@ protected static function newFactory() protected $fillable = [ 'feed_id', 'platform_channel_id', + 'language_id', 'is_active', 'priority' ]; @@ -62,6 +66,14 @@ public function platformChannel(): BelongsTo return $this->belongsTo(PlatformChannel::class); } + /** + * @return BelongsTo + */ + public function language(): BelongsTo + { + return $this->belongsTo(Language::class); + } + /** * @return HasMany */ @@ -70,4 +82,51 @@ public function keywords(): HasMany return $this->hasMany(Keyword::class, 'feed_id', 'feed_id') ->where('platform_channel_id', $this->platform_channel_id); } + + /** + * Check if the feed has the given language + */ + public function feedHasLanguage(int $languageId): bool + { + if (!$this->feed) { + return false; + } + + return $this->feed->languages()->where('languages.id', $languageId)->exists(); + } + + /** + * Check if the platform channel has the given language + */ + public function channelHasLanguage(int $languageId): bool + { + return $this->platformChannel && $this->platformChannel->language_id === $languageId; + } + + /** + * Check if the route's language is consistent with feed and channel + */ + public function hasConsistentLanguage(): bool + { + if (!$this->language_id) { + return false; + } + + return $this->feedHasLanguage($this->language_id) && $this->channelHasLanguage($this->language_id); + } + + /** + * Get common languages between feed and channel + */ + public function getCommonLanguages(): array + { + if (!$this->feed || !$this->platformChannel) { + return []; + } + + $feedLanguageIds = $this->feed->languages()->pluck('languages.id')->toArray(); + $channelLanguageId = $this->platformChannel->language_id; + + return in_array($channelLanguageId, $feedLanguageIds) ? [$channelLanguageId] : []; + } } diff --git a/backend/src/Domains/Feed/Requests/StoreRouteRequest.php b/backend/src/Domains/Feed/Requests/StoreRouteRequest.php new file mode 100644 index 0000000..765a980 --- /dev/null +++ b/backend/src/Domains/Feed/Requests/StoreRouteRequest.php @@ -0,0 +1,91 @@ +|string> + */ + public function rules(): array + { + return [ + 'feed_id' => 'required|exists:feeds,id', + 'platform_channel_id' => 'required|exists:platform_channels,id', + 'language_id' => 'required|exists:languages,id', + 'is_active' => 'boolean', + 'priority' => 'nullable|integer|min:0', + ]; + } + + /** + * Configure the validator instance. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $this->validateLanguageConsistency($validator); + }); + } + + /** + * Validate that the language is consistent between feed and channel + */ + protected function validateLanguageConsistency(Validator $validator): void + { + $feedId = $this->input('feed_id'); + $channelId = $this->input('platform_channel_id'); + $languageId = $this->input('language_id'); + + if (!$feedId || !$channelId || !$languageId) { + return; + } + + // Check if feed has the selected language + $feed = Feed::find($feedId); + if (!$feed || !$feed->languages()->where('languages.id', $languageId)->exists()) { + $validator->errors()->add('language_id', 'The selected feed does not support this language.'); + return; + } + + // Check if channel has the selected language + $channel = PlatformChannel::find($channelId); + if (!$channel || $channel->language_id !== (int)$languageId) { + $validator->errors()->add('language_id', 'The selected channel does not support this language.'); + return; + } + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'feed_id.required' => 'A feed must be selected.', + 'feed_id.exists' => 'The selected feed is invalid.', + 'platform_channel_id.required' => 'A platform channel must be selected.', + 'platform_channel_id.exists' => 'The selected platform channel is invalid.', + 'language_id.required' => 'A language must be selected.', + 'language_id.exists' => 'The selected language is invalid.', + 'priority.min' => 'Priority must be at least 0.', + ]; + } +} diff --git a/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php b/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php new file mode 100644 index 0000000..66d5ec1 --- /dev/null +++ b/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php @@ -0,0 +1,89 @@ +|string> + */ + public function rules(): array + { + return [ + 'language_id' => 'nullable|exists:languages,id', + 'is_active' => 'boolean', + 'priority' => 'nullable|integer|min:0', + ]; + } + + /** + * Configure the validator instance. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $this->validateLanguageConsistency($validator); + }); + } + + /** + * Validate that the language is consistent between feed and channel + */ + protected function validateLanguageConsistency(Validator $validator): void + { + $languageId = $this->input('language_id'); + + // If no language_id provided, skip validation (allowing null updates) + if (!$languageId) { + return; + } + + // Get feed and channel from route parameters + $feed = $this->route('feed'); + $channel = $this->route('channel'); + + if (!$feed || !$channel) { + return; + } + + // Check if feed has the selected language + if (!$feed->languages()->where('languages.id', $languageId)->exists()) { + $validator->errors()->add('language_id', 'The selected feed does not support this language.'); + return; + } + + // Check if channel has the selected language + if ($channel->language_id !== (int)$languageId) { + $validator->errors()->add('language_id', 'The selected channel does not support this language.'); + return; + } + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'language_id.exists' => 'The selected language is invalid.', + 'priority.min' => 'Priority must be at least 0.', + ]; + } +} diff --git a/backend/src/Domains/Feed/Resources/RouteResource.php b/backend/src/Domains/Feed/Resources/RouteResource.php index 09a421f..d863229 100644 --- a/backend/src/Domains/Feed/Resources/RouteResource.php +++ b/backend/src/Domains/Feed/Resources/RouteResource.php @@ -4,6 +4,7 @@ use Domains\Feed\Resources\FeedResource; use Domains\Platform\Resources\PlatformChannelResource; +use Domains\Settings\Resources\LanguageResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -20,12 +21,14 @@ public function toArray(Request $request): array 'id' => $this->id, 'feed_id' => $this->feed_id, 'platform_channel_id' => $this->platform_channel_id, + 'language_id' => $this->language_id, 'is_active' => $this->is_active, 'priority' => $this->priority, 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), 'feed' => new FeedResource($this->whenLoaded('feed')), 'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), + 'language' => new LanguageResource($this->whenLoaded('language')), 'keywords' => $this->whenLoaded('keywords', function () { return $this->keywords->map(function ($keyword) { return [ diff --git a/backend/src/Domains/Settings/Models/Language.php b/backend/src/Domains/Settings/Models/Language.php index 2f604f0..45ae526 100644 --- a/backend/src/Domains/Settings/Models/Language.php +++ b/backend/src/Domains/Settings/Models/Language.php @@ -3,6 +3,7 @@ namespace Domains\Settings\Models; use Domains\Feed\Models\Feed; +use Domains\Feed\Models\Route; use Domains\Platform\Models\PlatformChannel; use Domains\Platform\Models\PlatformInstance; use Domains\Settings\Factories\LanguageFactory; @@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Builder; class Language extends Model { @@ -59,4 +61,67 @@ public function feeds(): BelongsToMany ->withPivot(['url', 'settings', 'is_active', 'is_primary']) ->withTimestamps(); } + + /** + * @return HasMany + */ + public function routes(): HasMany + { + return $this->hasMany(Route::class); + } + + /** + * Scope to get languages that have both active feeds and active channels + * + * @param Builder $query + * @return Builder + */ + public function scopeAvailableForRoutes(Builder $query): Builder + { + return $query->where('is_active', true) + ->whereHas('feeds', function (Builder $feedQuery) { + $feedQuery->where('feeds.is_active', true) + ->where('feed_languages.is_active', true); + }) + ->whereHas('platformChannels', function (Builder $channelQuery) { + $channelQuery->where('platform_channels.is_active', true); + }); + } + + /** + * Scope to get languages with active feeds + * + * @param Builder $query + * @return Builder + */ + public function scopeWithActiveFeeds(Builder $query): Builder + { + return $query->whereHas('feeds', function (Builder $feedQuery) { + $feedQuery->where('feeds.is_active', true) + ->where('feed_languages.is_active', true); + }); + } + + /** + * Scope to get languages with active channels + * + * @param Builder $query + * @return Builder + */ + public function scopeWithActiveChannels(Builder $query): Builder + { + return $query->whereHas('platformChannels', function (Builder $channelQuery) { + $channelQuery->where('platform_channels.is_active', true); + }); + } + + /** + * Check if this language can be used for routes (has both feeds and channels) + */ + public function canBeUsedForRoutes(): bool + { + return $this->is_active && + $this->feeds()->where('feeds.is_active', true)->where('feed_languages.is_active', true)->exists() && + $this->platformChannels()->where('platform_channels.is_active', true)->exists(); + } } diff --git a/backend/src/Domains/Settings/Resources/LanguageResource.php b/backend/src/Domains/Settings/Resources/LanguageResource.php new file mode 100644 index 0000000..ac420df --- /dev/null +++ b/backend/src/Domains/Settings/Resources/LanguageResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'short_code' => $this->short_code, + 'name' => $this->name, + 'native_name' => $this->native_name, + 'is_active' => $this->is_active, + ]; + } +} diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerLanguageTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerLanguageTest.php new file mode 100644 index 0000000..b6ef523 --- /dev/null +++ b/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerLanguageTest.php @@ -0,0 +1,293 @@ +create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + $response = $this->postJson('/api/v1/routing', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + // 'language_id' is missing + 'is_active' => true, + 'priority' => 50 + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['language_id']); + } + + public function test_store_route_validates_language_consistency_with_feed(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + // Attach only language2 to feed, but request language1 + $feed->languages()->attach($language2->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $response = $this->postJson('/api/v1/routing', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['language_id']); + $this->assertStringContainsString('The selected feed does not support this language', + $response->json('errors.language_id.0')); + } + + public function test_store_route_validates_language_consistency_with_channel(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language2->id]); + + // Attach language1 to feed, but channel has language2 + $feed->languages()->attach($language1->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $response = $this->postJson('/api/v1/routing', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['language_id']); + $this->assertStringContainsString('The selected channel does not support this language', + $response->json('errors.language_id.0')); + } + + public function test_store_route_succeeds_with_valid_language_consistency(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $response = $this->postJson('/api/v1/routing', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response->assertStatus(201); + $response->assertJson([ + 'success' => true, + 'message' => 'Routing configuration created successfully!', + 'data' => [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ] + ]); + + $this->assertDatabaseHas('routes', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + } + + public function test_update_route_validates_language_consistency(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + // Attach both languages to feed + $feed->languages()->attach([ + $language1->id => [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ], + $language2->id => [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => false + ] + ]); + + // Create route with language1 + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + // Try to update to language2, which feed supports but channel doesn't + $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", [ + 'language_id' => $language2->id, + 'is_active' => false, + 'priority' => 75 + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['language_id']); + $this->assertStringContainsString('The selected channel does not support this language', + $response->json('errors.language_id.0')); + } + + public function test_update_route_succeeds_with_valid_language(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + // Create route + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", [ + 'language_id' => $language->id, + 'is_active' => false, + 'priority' => 75 + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'message' => 'Routing configuration updated successfully!', + 'data' => [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => false, + 'priority' => 75 + ] + ]); + + $this->assertDatabaseHas('routes', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => false, + 'priority' => 75 + ]); + } + + public function test_index_includes_language_relationship(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Create route + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response = $this->getJson('/api/v1/routing'); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'data' => [ + [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'language' => [ + 'id' => $language->id, + 'name' => $language->name, + 'short_code' => $language->short_code + ] + ] + ] + ]); + } + + public function test_show_includes_language_relationship(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Create route + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}"); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'data' => [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'language' => [ + 'id' => $language->id, + 'name' => $language->name, + 'short_code' => $language->short_code + ] + ] + ]); + } +} diff --git a/backend/tests/Unit/Models/LanguageTest.php b/backend/tests/Unit/Models/LanguageTest.php index f7efec0..e19a26d 100644 --- a/backend/tests/Unit/Models/LanguageTest.php +++ b/backend/tests/Unit/Models/LanguageTest.php @@ -3,9 +3,9 @@ namespace Tests\Unit\Models; use Domains\Feed\Models\Feed; -use Domains\Settings\Models\Language; +use Domains\Feed\Models\Route; use Domains\Platform\Models\PlatformChannel; -use Domains\Platform\Models\PlatformInstance; +use Domains\Settings\Models\Language; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -13,335 +13,139 @@ class LanguageTest extends TestCase { use RefreshDatabase; - public function test_fillable_fields(): void - { - $fillableFields = ['short_code', 'name', 'native_name', 'is_active']; - $language = new Language(); - - $this->assertEquals($fillableFields, $language->getFillable()); - } - - public function test_table_name(): void - { - $language = new Language(); - - $this->assertEquals('languages', $language->getTable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $language = Language::factory()->create(['is_active' => '1']); - - $this->assertIsBool($language->is_active); - $this->assertTrue($language->is_active); - - $language->update(['is_active' => '0']); - $language->refresh(); - - $this->assertIsBool($language->is_active); - $this->assertFalse($language->is_active); - } - - public function test_belongs_to_many_platform_instances_relationship(): void + public function test_routes_relationship(): void { $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - - // Attach with required platform_language_id - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2] - ]); - - $instances = $language->platformInstances; - - $this->assertCount(2, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - } - - public function test_has_many_platform_channels_relationship(): void - { - $language = Language::factory()->create(); - + $feed1 = Feed::factory()->create(); + $feed2 = Feed::factory()->create(); $channel1 = PlatformChannel::factory()->create(['language_id' => $language->id]); $channel2 = PlatformChannel::factory()->create(['language_id' => $language->id]); - // Create channel for different language - $otherLanguage = Language::factory()->create(); - PlatformChannel::factory()->create(['language_id' => $otherLanguage->id]); - - $channels = $language->platformChannels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - $this->assertInstanceOf(PlatformChannel::class, $channels->first()); - } - - public function test_belongs_to_many_feeds_relationship(): void - { - $language = Language::factory()->create(); - - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - - // Attach feeds to this language - $language->feeds()->attach($feed1->id, [ - 'url' => $feed1->url, + $route1 = Route::create([ + 'feed_id' => $feed1->id, + 'platform_channel_id' => $channel1->id, + 'language_id' => $language->id, 'is_active' => true, - 'is_primary' => true + 'priority' => 50 ]); - $language->feeds()->attach($feed2->id, [ - 'url' => $feed2->url, - 'is_active' => true, - 'is_primary' => false + + $route2 = Route::create([ + 'feed_id' => $feed2->id, + 'platform_channel_id' => $channel2->id, + 'language_id' => $language->id, + 'is_active' => false, + 'priority' => 60 ]); + + $routes = $language->routes; + + $this->assertCount(2, $routes); + $this->assertTrue($routes->contains('id', $route1->id)); + $this->assertTrue($routes->contains('id', $route2->id)); + } + + public function test_available_for_routes_scope(): void + { + // Create languages + $language1 = Language::factory()->create(['is_active' => true]); + $language2 = Language::factory()->create(['is_active' => true]); + $language3 = Language::factory()->create(['is_active' => false]); // inactive language + + // Create feeds and channels + $feed1 = Feed::factory()->create(['is_active' => true]); + $feed2 = Feed::factory()->create(['is_active' => false]); // inactive feed + $channel1 = PlatformChannel::factory()->create(['language_id' => $language1->id, 'is_active' => true]); + $channel2 = PlatformChannel::factory()->create(['language_id' => $language2->id, 'is_active' => false]); // inactive channel + + // Attach languages to feeds + $feed1->languages()->attach($language1->id, ['url' => $feed1->url, 'is_active' => true, 'is_primary' => true]); + $feed2->languages()->attach($language2->id, ['url' => $feed2->url, 'is_active' => true, 'is_primary' => true]); + $feed1->languages()->attach($language3->id, ['url' => $feed1->url, 'is_active' => true, 'is_primary' => false]); + + $availableLanguages = Language::availableForRoutes()->get(); + + // Only language1 should be available (has active feed and active channel) + $this->assertCount(1, $availableLanguages); + $this->assertEquals($language1->id, $availableLanguages->first()->id); + } + + public function test_with_active_feeds_scope(): void + { + $language1 = Language::factory()->create(['is_active' => true]); + $language2 = Language::factory()->create(['is_active' => true]); - // Create feed for different language - $otherLanguage = Language::factory()->create(); - $feed3 = Feed::factory()->create(); - $otherLanguage->feeds()->attach($feed3->id, [ - 'url' => $feed3->url, - 'is_active' => true, - 'is_primary' => true - ]); + $activeFeed = Feed::factory()->create(['is_active' => true]); + $inactiveFeed = Feed::factory()->create(['is_active' => false]); - $feeds = $language->feeds; + // Attach languages to feeds + $activeFeed->languages()->attach($language1->id, ['url' => $activeFeed->url, 'is_active' => true, 'is_primary' => true]); + $inactiveFeed->languages()->attach($language2->id, ['url' => $inactiveFeed->url, 'is_active' => true, 'is_primary' => true]); - $this->assertCount(2, $feeds); - $this->assertTrue($feeds->contains('id', $feed1->id)); - $this->assertTrue($feeds->contains('id', $feed2->id)); - $this->assertFalse($feeds->contains('id', $feed3->id)); - $this->assertInstanceOf(Feed::class, $feeds->first()); + $languagesWithActiveFeeds = Language::withActiveFeeds()->get(); + + $this->assertCount(1, $languagesWithActiveFeeds); + $this->assertEquals($language1->id, $languagesWithActiveFeeds->first()->id); } - public function test_language_creation_with_factory(): void + public function test_with_active_channels_scope(): void { - $language = Language::factory()->create(); - - $this->assertInstanceOf(Language::class, $language); - $this->assertIsString($language->short_code); - $this->assertIsString($language->name); - $this->assertTrue($language->is_active); - } - - public function test_language_creation_with_explicit_values(): void - { - $language = Language::create([ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => false - ]); - - $this->assertEquals('fr', $language->short_code); - $this->assertEquals('French', $language->name); - $this->assertEquals('Français', $language->native_name); - $this->assertFalse($language->is_active); - } - - public function test_language_factory_states(): void - { - $inactiveLanguage = Language::factory()->inactive()->create(); - $this->assertFalse($inactiveLanguage->is_active); - - $englishLanguage = Language::factory()->english()->create(); - $this->assertEquals('en', $englishLanguage->short_code); - $this->assertEquals('English', $englishLanguage->name); - $this->assertEquals('English', $englishLanguage->native_name); - } - - public function test_language_update(): void - { - $language = Language::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $language->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $language->refresh(); - - $this->assertEquals('Updated Name', $language->name); - $this->assertFalse($language->is_active); - } - - public function test_language_deletion(): void - { - $language = Language::factory()->create(); - $languageId = $language->id; - - $language->delete(); - - $this->assertDatabaseMissing('languages', ['id' => $languageId]); - } - - public function test_language_can_have_null_native_name(): void - { - $language = Language::factory()->create(['native_name' => null]); - - $this->assertNull($language->native_name); - } - - public function test_language_can_have_empty_native_name(): void - { - $language = Language::factory()->create(['native_name' => '']); - - $this->assertEquals('', $language->native_name); - } - - public function test_language_short_code_variations(): void - { - $shortCodes = ['en', 'fr', 'es', 'de', 'zh', 'pt', 'nl', 'it']; + $language1 = Language::factory()->create(['is_active' => true]); + $language2 = Language::factory()->create(['is_active' => true]); - foreach ($shortCodes as $code) { - $language = Language::factory()->create(['short_code' => $code]); - $this->assertEquals($code, $language->short_code); - } + PlatformChannel::factory()->create(['language_id' => $language1->id, 'is_active' => true]); + PlatformChannel::factory()->create(['language_id' => $language2->id, 'is_active' => false]); + + $languagesWithActiveChannels = Language::withActiveChannels()->get(); + + $this->assertCount(1, $languagesWithActiveChannels); + $this->assertEquals($language1->id, $languagesWithActiveChannels->first()->id); } - public function test_language_timestamps(): void + public function test_can_be_used_for_routes_method(): void { - $language = Language::factory()->create(); + $language = Language::factory()->create(['is_active' => true]); + $feed = Feed::factory()->create(['is_active' => true]); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id, 'is_active' => true]); - $this->assertNotNull($language->created_at); - $this->assertNotNull($language->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at); + // Attach language to feed + $feed->languages()->attach($language->id, ['url' => $feed->url, 'is_active' => true, 'is_primary' => true]); + + $this->assertTrue($language->canBeUsedForRoutes()); } - public function test_language_can_have_multiple_platform_instances(): void + public function test_can_be_used_for_routes_method_returns_false_when_language_inactive(): void { - $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - $instance3 = PlatformInstance::factory()->create(); + $language = Language::factory()->create(['is_active' => false]); + $feed = Feed::factory()->create(['is_active' => true]); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id, 'is_active' => true]); - // Attach with required platform_language_id values - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2], - $instance3->id => ['platform_language_id' => 3] - ]); + // Attach language to feed + $feed->languages()->attach($language->id, ['url' => $feed->url, 'is_active' => true, 'is_primary' => true]); - $instances = $language->platformInstances; - - $this->assertCount(3, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - $this->assertTrue($instances->contains('id', $instance3->id)); + $this->assertFalse($language->canBeUsedForRoutes()); } - public function test_language_platform_instances_relationship_is_empty_by_default(): void + public function test_can_be_used_for_routes_method_returns_false_when_no_active_feeds(): void { - $language = Language::factory()->create(); + $language = Language::factory()->create(['is_active' => true]); + $feed = Feed::factory()->create(['is_active' => false]); // inactive feed + $channel = PlatformChannel::factory()->create(['language_id' => $language->id, 'is_active' => true]); - $this->assertCount(0, $language->platformInstances); + // Attach language to inactive feed + $feed->languages()->attach($language->id, ['url' => $feed->url, 'is_active' => true, 'is_primary' => true]); + + $this->assertFalse($language->canBeUsedForRoutes()); } - public function test_language_platform_channels_relationship_is_empty_by_default(): void + public function test_can_be_used_for_routes_method_returns_false_when_no_active_channels(): void { - $language = Language::factory()->create(); + $language = Language::factory()->create(['is_active' => true]); + $feed = Feed::factory()->create(['is_active' => true]); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id, 'is_active' => false]); // inactive channel - $this->assertCount(0, $language->platformChannels); + // Attach language to feed + $feed->languages()->attach($language->id, ['url' => $feed->url, 'is_active' => true, 'is_primary' => true]); + + $this->assertFalse($language->canBeUsedForRoutes()); } - - public function test_language_feeds_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->feeds); - } - - public function test_multiple_languages_with_same_name_different_regions(): void - { - $englishUS = Language::factory()->create([ - 'short_code' => 'en-US', - 'name' => 'English (United States)', - 'native_name' => 'English' - ]); - - $englishGB = Language::factory()->create([ - 'short_code' => 'en-GB', - 'name' => 'English (United Kingdom)', - 'native_name' => 'English' - ]); - - $this->assertEquals('English', $englishUS->native_name); - $this->assertEquals('English', $englishGB->native_name); - $this->assertNotEquals($englishUS->short_code, $englishGB->short_code); - $this->assertNotEquals($englishUS->name, $englishGB->name); - } - - public function test_language_with_complex_native_name(): void - { - $complexLanguages = [ - ['short_code' => 'zh-CN', 'name' => 'Chinese (Simplified)', 'native_name' => '简体中文'], - ['short_code' => 'zh-TW', 'name' => 'Chinese (Traditional)', 'native_name' => '繁體中文'], - ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية'], - ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский'], - ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語'], - ]; - - foreach ($complexLanguages as $langData) { - $language = Language::factory()->create($langData); - - $this->assertEquals($langData['short_code'], $language->short_code); - $this->assertEquals($langData['name'], $language->name); - $this->assertEquals($langData['native_name'], $language->native_name); - } - } - - public function test_language_active_and_inactive_states(): void - { - $activeLanguage = Language::factory()->create(['is_active' => true]); - $inactiveLanguage = Language::factory()->create(['is_active' => false]); - - $this->assertTrue($activeLanguage->is_active); - $this->assertFalse($inactiveLanguage->is_active); - } - - public function test_language_relationships_maintain_referential_integrity(): void - { - $language = Language::factory()->create(); - - // Create related models - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); - $feed = Feed::factory()->create(); - $language->feeds()->attach($feed->id, [ - 'url' => $feed->url, - 'is_active' => true, - 'is_primary' => true - ]); - - // Attach instance - $language->platformInstances()->attach($instance->id, [ - 'platform_language_id' => 1, - 'is_default' => true - ]); - - // Verify all relationships work - $this->assertCount(1, $language->platformInstances); - $this->assertCount(1, $language->platformChannels); - $this->assertCount(1, $language->feeds); - - $this->assertEquals($language->id, $channel->language_id); - $this->assertTrue($language->feeds->contains('id', $feed->id)); - } - - public function test_language_factory_unique_constraints(): void - { - // The factory should generate unique short codes - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - - $this->assertNotEquals($language1->short_code, $language2->short_code); - $this->assertNotEquals($language1->name, $language2->name); - } -} \ No newline at end of file +} diff --git a/backend/tests/Unit/Models/RouteTest.php b/backend/tests/Unit/Models/RouteTest.php index 6a17356..900e8f7 100644 --- a/backend/tests/Unit/Models/RouteTest.php +++ b/backend/tests/Unit/Models/RouteTest.php @@ -6,6 +6,7 @@ use Domains\Article\Models\Keyword; use Domains\Platform\Models\PlatformChannel; use Domains\Feed\Models\Route; +use Domains\Settings\Models\Language; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -15,7 +16,7 @@ class RouteTest extends TestCase public function test_fillable_fields(): void { - $fillableFields = ['feed_id', 'platform_channel_id', 'is_active', 'priority']; + $fillableFields = ['feed_id', 'platform_channel_id', 'language_id', 'is_active', 'priority']; $route = new Route(); $this->assertEquals($fillableFields, $route->getFillable()); @@ -258,4 +259,218 @@ public function test_route_with_multiple_keywords_active_and_inactive(): void $this->assertEquals('active_keyword', $activeKeywords->first()->keyword); $this->assertEquals('inactive_keyword', $inactiveKeywords->first()->keyword); } + + public function test_belongs_to_language_relationship(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $this->assertInstanceOf(Language::class, $route->language); + $this->assertEquals($language->id, $route->language->id); + $this->assertEquals($language->name, $route->language->name); + } + + public function test_route_creation_with_language(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 75 + ]); + + $this->assertEquals($language->id, $route->language_id); + $this->assertEquals($feed->id, $route->feed_id); + $this->assertEquals($channel->id, $route->platform_channel_id); + $this->assertTrue($route->is_active); + $this->assertEquals(75, $route->priority); + } + + public function test_feed_has_language_method(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + // Attach only language1 to feed + $feed->languages()->attach($language1->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load('feed'); + + $this->assertTrue($route->feedHasLanguage($language1->id)); + $this->assertFalse($route->feedHasLanguage($language2->id)); + } + + public function test_channel_has_language_method(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load('platformChannel'); + + $this->assertTrue($route->channelHasLanguage($language1->id)); + $this->assertFalse($route->channelHasLanguage($language2->id)); + } + + public function test_has_consistent_language_method(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load(['feed', 'platformChannel']); + + $this->assertTrue($route->hasConsistentLanguage()); + } + + public function test_has_consistent_language_method_returns_false_for_mismatched_languages(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language2->id]); + + // Attach only language1 to feed, but channel has language2 + $feed->languages()->attach($language1->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load(['feed', 'platformChannel']); + + $this->assertFalse($route->hasConsistentLanguage()); + } + + public function test_get_common_languages_method(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + // Attach both languages to feed + $feed->languages()->attach([ + $language1->id => [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ], + $language2->id => [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => false + ] + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load(['feed', 'platformChannel']); + + $commonLanguages = $route->getCommonLanguages(); + + $this->assertEquals([$language1->id], $commonLanguages); + } + + public function test_get_common_languages_method_returns_empty_for_no_match(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language2->id]); + + // Attach only language1 to feed, but channel has language2 + $feed->languages()->attach($language1->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $route = Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]); + + $route->load(['feed', 'platformChannel']); + + $commonLanguages = $route->getCommonLanguages(); + + $this->assertEmpty($commonLanguages); + } } \ No newline at end of file diff --git a/backend/tests/Unit/Requests/StoreRouteRequestTest.php b/backend/tests/Unit/Requests/StoreRouteRequestTest.php new file mode 100644 index 0000000..b029462 --- /dev/null +++ b/backend/tests/Unit/Requests/StoreRouteRequestTest.php @@ -0,0 +1,189 @@ +assertTrue($request->authorize()); + } + + public function test_rules_include_required_fields(): void + { + $request = new StoreRouteRequest(); + $rules = $request->rules(); + + $this->assertArrayHasKey('feed_id', $rules); + $this->assertArrayHasKey('platform_channel_id', $rules); + $this->assertArrayHasKey('language_id', $rules); + $this->assertArrayHasKey('is_active', $rules); + $this->assertArrayHasKey('priority', $rules); + + $this->assertStringContainsString('required', $rules['feed_id']); + $this->assertStringContainsString('exists:feeds,id', $rules['feed_id']); + $this->assertStringContainsString('required', $rules['platform_channel_id']); + $this->assertStringContainsString('exists:platform_channels,id', $rules['platform_channel_id']); + $this->assertStringContainsString('required', $rules['language_id']); + $this->assertStringContainsString('exists:languages,id', $rules['language_id']); + } + + public function test_validation_passes_with_valid_data(): void + { + $language = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); + + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $data = [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language->id, + 'is_active' => true, + 'priority' => 50 + ]; + + $request = new StoreRouteRequest(); + $rules = $request->rules(); + $validator = Validator::make($data, $rules); + + $this->assertTrue($validator->passes()); + } + + public function test_validation_fails_without_required_fields(): void + { + $data = []; + + $request = new StoreRouteRequest(); + $rules = $request->rules(); + $validator = Validator::make($data, $rules); + + $this->assertFalse($validator->passes()); + $this->assertArrayHasKey('feed_id', $validator->errors()->toArray()); + $this->assertArrayHasKey('platform_channel_id', $validator->errors()->toArray()); + $this->assertArrayHasKey('language_id', $validator->errors()->toArray()); + } + + public function test_validation_fails_with_invalid_foreign_keys(): void + { + $data = [ + 'feed_id' => 999, + 'platform_channel_id' => 999, + 'language_id' => 999, + 'is_active' => true, + 'priority' => 50 + ]; + + $request = new StoreRouteRequest(); + $rules = $request->rules(); + $validator = Validator::make($data, $rules); + + $this->assertFalse($validator->passes()); + $this->assertArrayHasKey('feed_id', $validator->errors()->toArray()); + $this->assertArrayHasKey('platform_channel_id', $validator->errors()->toArray()); + $this->assertArrayHasKey('language_id', $validator->errors()->toArray()); + } + + public function test_language_consistency_validation_fails_when_feed_lacks_language(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language1->id]); + + // Attach only language2 to feed, but request language1 + $feed->languages()->attach($language2->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $data = [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]; + + $request = new StoreRouteRequest(); + $request->merge($data); + + $rules = $request->rules(); + $validator = Validator::make($data, $rules); + + // Call the custom validation method + $request->withValidator($validator); + + $this->assertFalse($validator->passes()); + $this->assertStringContainsString('The selected feed does not support this language', + $validator->errors()->first('language_id')); + } + + public function test_language_consistency_validation_fails_when_channel_lacks_language(): void + { + $language1 = Language::factory()->create(); + $language2 = Language::factory()->create(); + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $language2->id]); + + // Attach language1 to feed, but channel has language2 + $feed->languages()->attach($language1->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + + $data = [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'language_id' => $language1->id, + 'is_active' => true, + 'priority' => 50 + ]; + + $request = new StoreRouteRequest(); + $request->merge($data); + + $rules = $request->rules(); + $validator = Validator::make($data, $rules); + + // Call the custom validation method + $request->withValidator($validator); + + $this->assertFalse($validator->passes()); + $this->assertStringContainsString('The selected channel does not support this language', + $validator->errors()->first('language_id')); + } + + public function test_custom_messages(): void + { + $request = new StoreRouteRequest(); + $messages = $request->messages(); + + $this->assertArrayHasKey('feed_id.required', $messages); + $this->assertArrayHasKey('platform_channel_id.required', $messages); + $this->assertArrayHasKey('language_id.required', $messages); + $this->assertEquals('A feed must be selected.', $messages['feed_id.required']); + $this->assertEquals('A platform channel must be selected.', $messages['platform_channel_id.required']); + $this->assertEquals('A language must be selected.', $messages['language_id.required']); + } +}