From 35e4260c87cb53234df80b2b2169d41e57afe3ae Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 7 Mar 2026 10:43:48 +0100 Subject: [PATCH 01/12] 76 - Lock feed language to channel language in onboarding wizard --- app/Livewire/Onboarding.php | 131 ++++++++++++++++-- config/feed.php | 9 +- config/languages.php | 61 ++++---- resources/views/livewire/onboarding.blade.php | 48 ++++--- shell.nix | 11 ++ 5 files changed, 199 insertions(+), 61 deletions(-) diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index 5e05ff9..dc5eda3 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -47,6 +47,7 @@ class Onboarding extends Component // State public array $formErrors = []; public bool $isLoading = false; + private ?int $previousChannelLanguageId = null; protected LemmyAuthService $lemmyAuthService; @@ -104,6 +105,11 @@ public function nextStep(): void { $this->step++; $this->formErrors = []; + + // When entering feed step, inherit language from channel + if ($this->step === 4 && $this->channelLanguageId) { + $this->feedLanguageId = $this->channelLanguageId; + } } public function previousStep(): void @@ -210,24 +216,37 @@ public function createFeed(): void $this->formErrors = []; $this->isLoading = true; + // Get available provider codes for validation + $availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(','); + $this->validate([ 'feedName' => 'required|string|max:255', - 'feedProvider' => 'required|in:belga,vrt', + 'feedProvider' => "required|in:{$availableProviders}", 'feedLanguageId' => 'required|exists:languages,id', 'feedDescription' => 'nullable|string|max:1000', ]); try { - // Map provider to URL - $url = $this->feedProvider === 'vrt' - ? 'https://www.vrt.be/vrtnws/en/' - : 'https://www.belganewsagency.eu/'; + // Get language short code + $language = Language::find($this->feedLanguageId); + $langCode = $language->short_code; + + // Look up URL from config + $url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url"); + + if (!$url) { + $this->formErrors['general'] = 'Invalid provider and language combination.'; + $this->isLoading = false; + return; + } + + $providerConfig = config("feed.providers.{$this->feedProvider}"); Feed::firstOrCreate( ['url' => $url], [ 'name' => $this->feedName, - 'type' => 'website', + 'type' => $providerConfig['type'] ?? 'website', 'provider' => $this->feedProvider, 'language_id' => $this->feedLanguageId, 'description' => $this->feedDescription ?: null, @@ -255,6 +274,16 @@ public function createChannel(): void 'channelDescription' => 'nullable|string|max:1000', ]); + // If language changed, reset feed form + if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) { + $this->feedName = ''; + $this->feedProvider = ''; + $this->feedDescription = ''; + $this->routeFeedId = null; + $this->routeChannelId = null; + } + $this->previousChannelLanguageId = $this->channelLanguageId; + try { $platformInstance = PlatformInstance::findOrFail($this->platformInstanceId); @@ -340,23 +369,97 @@ public function completeOnboarding(): void $this->redirect(route('dashboard')); } + /** + * Get language codes that have at least one active provider. + */ + public function getAvailableLanguageCodes(): array + { + $providers = config('feed.providers', []); + $languageCodes = []; + + foreach ($providers as $provider) { + if (!($provider['is_active'] ?? false)) { + continue; + } + foreach (array_keys($provider['languages'] ?? []) as $code) { + $languageCodes[$code] = true; + } + } + + return array_keys($languageCodes); + } + + /** + * Get providers available for the current channel language. + */ + public function getProvidersForLanguage(): array + { + if (!$this->channelLanguageId) { + return []; + } + + $language = Language::find($this->channelLanguageId); + if (!$language) { + return []; + } + + $langCode = $language->short_code; + $providers = config('feed.providers', []); + $available = []; + + foreach ($providers as $key => $provider) { + if (!($provider['is_active'] ?? false)) { + continue; + } + if (isset($provider['languages'][$langCode])) { + $available[] = [ + 'code' => $provider['code'], + 'name' => $provider['name'], + 'description' => $provider['description'] ?? '', + ]; + } + } + + return $available; + } + + /** + * Get the current channel language model. + */ + public function getChannelLanguage(): ?Language + { + if (!$this->channelLanguageId) { + return null; + } + return Language::find($this->channelLanguageId); + } + public function render() { - $languages = Language::where('is_active', true)->orderBy('name')->get(); - $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); + // For channel step: only show languages that have providers + $availableCodes = $this->getAvailableLanguageCodes(); + $wizardLanguages = Language::where('is_active', true) + ->whereIn('short_code', $availableCodes) + ->orderBy('name') + ->get(); - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active'] ?? false) - ->values(); + $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); + $feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get(); + $channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get(); + + // For feed step: only show providers for the channel's language + $feedProviders = collect($this->getProvidersForLanguage()); + + // Get channel language for display + $channelLanguage = $this->getChannelLanguage(); return view('livewire.onboarding', [ - 'languages' => $languages, + 'wizardLanguages' => $wizardLanguages, 'platformInstances' => $platformInstances, 'feeds' => $feeds, 'channels' => $channels, 'feedProviders' => $feedProviders, + 'channelLanguage' => $channelLanguage, ])->layout('layouts.onboarding'); } } diff --git a/config/feed.php b/config/feed.php index 2c4288d..b6bdfd7 100644 --- a/config/feed.php +++ b/config/feed.php @@ -19,6 +19,10 @@ 'description' => 'Belgian public broadcaster news', 'type' => 'website', 'is_active' => true, + 'languages' => [ + 'en' => ['url' => 'https://www.vrt.be/vrtnws/en/'], + 'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'], + ], 'parsers' => [ 'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, 'article' => \App\Services\Parsers\VrtArticleParser::class, @@ -27,10 +31,13 @@ ], 'belga' => [ 'code' => 'belga', - 'name' => 'Belga News Agency', + 'name' => 'Belga News Agency', 'description' => 'Belgian national news agency', 'type' => 'rss', 'is_active' => true, + 'languages' => [ + 'en' => ['url' => 'https://www.belganewsagency.eu/'], + ], 'parsers' => [ 'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class, 'article' => \App\Services\Parsers\BelgaArticleParser::class, diff --git a/config/languages.php b/config/languages.php index d0f8c7f..6b63ce0 100644 --- a/config/languages.php +++ b/config/languages.php @@ -12,30 +12,43 @@ */ 'supported' => [ - 'en' => [ - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - 'is_active' => true, - ], - 'nl' => [ - 'short_code' => 'nl', - 'name' => 'Dutch', - 'native_name' => 'Nederlands', - 'is_active' => true, - ], - 'fr' => [ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => true, - ], - 'de' => [ - 'short_code' => 'de', - 'name' => 'German', - 'native_name' => 'Deutsch', - 'is_active' => true, - ], + 'en' => ['short_code' => 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true], + 'nl' => ['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true], + 'fr' => ['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true], + 'de' => ['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true], + 'es' => ['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true], + 'it' => ['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true], + 'pt' => ['short_code' => 'pt', 'name' => 'Portuguese', 'native_name' => 'Português', 'is_active' => true], + 'pl' => ['short_code' => 'pl', 'name' => 'Polish', 'native_name' => 'Polski', 'is_active' => true], + 'ru' => ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский', 'is_active' => true], + 'uk' => ['short_code' => 'uk', 'name' => 'Ukrainian', 'native_name' => 'Українська', 'is_active' => true], + 'cs' => ['short_code' => 'cs', 'name' => 'Czech', 'native_name' => 'Čeština', 'is_active' => true], + 'sk' => ['short_code' => 'sk', 'name' => 'Slovak', 'native_name' => 'Slovenčina', 'is_active' => true], + 'hu' => ['short_code' => 'hu', 'name' => 'Hungarian', 'native_name' => 'Magyar', 'is_active' => true], + 'ro' => ['short_code' => 'ro', 'name' => 'Romanian', 'native_name' => 'Română', 'is_active' => true], + 'bg' => ['short_code' => 'bg', 'name' => 'Bulgarian', 'native_name' => 'Български', 'is_active' => true], + 'hr' => ['short_code' => 'hr', 'name' => 'Croatian', 'native_name' => 'Hrvatski', 'is_active' => true], + 'sl' => ['short_code' => 'sl', 'name' => 'Slovenian', 'native_name' => 'Slovenščina', 'is_active' => true], + 'sr' => ['short_code' => 'sr', 'name' => 'Serbian', 'native_name' => 'Српски', 'is_active' => true], + 'el' => ['short_code' => 'el', 'name' => 'Greek', 'native_name' => 'Ελληνικά', 'is_active' => true], + 'tr' => ['short_code' => 'tr', 'name' => 'Turkish', 'native_name' => 'Türkçe', 'is_active' => true], + 'da' => ['short_code' => 'da', 'name' => 'Danish', 'native_name' => 'Dansk', 'is_active' => true], + 'sv' => ['short_code' => 'sv', 'name' => 'Swedish', 'native_name' => 'Svenska', 'is_active' => true], + 'no' => ['short_code' => 'no', 'name' => 'Norwegian', 'native_name' => 'Norsk', 'is_active' => true], + 'fi' => ['short_code' => 'fi', 'name' => 'Finnish', 'native_name' => 'Suomi', 'is_active' => true], + 'et' => ['short_code' => 'et', 'name' => 'Estonian', 'native_name' => 'Eesti', 'is_active' => true], + 'lv' => ['short_code' => 'lv', 'name' => 'Latvian', 'native_name' => 'Latviešu', 'is_active' => true], + 'lt' => ['short_code' => 'lt', 'name' => 'Lithuanian', 'native_name' => 'Lietuvių', 'is_active' => true], + 'ja' => ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語', 'is_active' => true], + 'zh' => ['short_code' => 'zh', 'name' => 'Chinese', 'native_name' => '中文', 'is_active' => true], + 'ko' => ['short_code' => 'ko', 'name' => 'Korean', 'native_name' => '한국어', 'is_active' => true], + 'ar' => ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية', 'is_active' => true], + 'he' => ['short_code' => 'he', 'name' => 'Hebrew', 'native_name' => 'עברית', 'is_active' => true], + 'hi' => ['short_code' => 'hi', 'name' => 'Hindi', 'native_name' => 'हिन्दी', 'is_active' => true], + 'th' => ['short_code' => 'th', 'name' => 'Thai', 'native_name' => 'ไทย', 'is_active' => true], + 'vi' => ['short_code' => 'vi', 'name' => 'Vietnamese', 'native_name' => 'Tiếng Việt', 'is_active' => true], + 'id' => ['short_code' => 'id', 'name' => 'Indonesian', 'native_name' => 'Bahasa Indonesia', 'is_active' => true], + 'ms' => ['short_code' => 'ms', 'name' => 'Malay', 'native_name' => 'Bahasa Melayu', 'is_active' => true], ], /* diff --git a/resources/views/livewire/onboarding.blade.php b/resources/views/livewire/onboarding.blade.php index 24b6419..f08f2d6 100644 --- a/resources/views/livewire/onboarding.blade.php +++ b/resources/views/livewire/onboarding.blade.php @@ -241,7 +241,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc required > - @foreach ($languages as $language) + @foreach ($wizardLanguages as $language) @endforeach @@ -316,6 +316,25 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc @error('feedName')

{{ $message }}

@enderror +
+ + +

Inherited from your channel

+
+
-
- - - @error('feedLanguageId')

{{ $message }}

@enderror -
-
+
+
+

+ Publishing Interval (minutes) +

+

+ Minimum time between publishing articles. Set to 0 for no delay. +

+
+
+ + +
+
+ @error('articlePublishingInterval') +

{{ $message }}

+ @enderror +

diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php index 95e8ead..e41b8c3 100644 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php @@ -20,6 +20,7 @@ public function test_index_returns_current_settings(): void 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ], 'message' ]) @@ -90,12 +91,58 @@ public function test_update_accepts_partial_updates(): void ] ]); - // Should still have structure for both settings + // Should still have structure for all settings $response->assertJsonStructure([ 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ] ]); } + + public function test_index_returns_article_publishing_interval(): void + { + $response = $this->getJson('/api/v1/settings'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'article_publishing_interval', + ], + ]) + ->assertJsonPath('data.article_publishing_interval', 5); + } + + public function test_update_accepts_valid_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 15, + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.article_publishing_interval', 15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + } + + public function test_update_rejects_negative_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => -5, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } + + public function test_update_rejects_non_integer_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 'abc', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } } diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 9e8f6cf..af12d93 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -7,6 +7,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; +use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -288,6 +289,153 @@ public function test_handle_fetches_article_data_before_publishing(): void $this->assertTrue(true); } + public function test_handle_skips_publishing_when_last_publication_within_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 3 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(3), + ]); + Setting::setArticlePublishingInterval(10); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + // Neither should be called + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_beyond_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 15 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(15), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_interval_is_zero(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was just now, but interval is 0 + ArticlePublication::factory()->create([ + 'published_at' => now(), + ]); + Setting::setArticlePublishingInterval(0); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_exactly_at_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(10), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_no_previous_publications_exist(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php new file mode 100644 index 0000000..1165b81 --- /dev/null +++ b/tests/Unit/Models/SettingTest.php @@ -0,0 +1,42 @@ +assertSame(5, Setting::getArticlePublishingInterval()); + } + + public function test_get_article_publishing_interval_returns_stored_value(): void + { + Setting::set('article_publishing_interval', '10'); + + $this->assertSame(10, Setting::getArticlePublishingInterval()); + } + + public function test_set_article_publishing_interval_persists_value(): void + { + Setting::setArticlePublishingInterval(15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + $this->assertDatabaseHas('settings', [ + 'key' => 'article_publishing_interval', + 'value' => '15', + ]); + } + + public function test_set_article_publishing_interval_zero(): void + { + Setting::setArticlePublishingInterval(0); + + $this->assertSame(0, Setting::getArticlePublishingInterval()); + } +}