From 2a4d4ad84da755dff5a1da46efc925948617842d Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 1 Aug 2026 13:43:50 +0200 Subject: [PATCH 1/6] 115 - Add Belga JSON discovery parser for press-release API --- .gitignore | 1 + app/Services/Parsers/BelgaHomepageParser.php | 25 +++++ .../Parsers/BelgaHomepageParserAdapter.php | 34 ++++++ tests/Fixtures/belga-pressreleases.json | 1 + .../Parsers/BelgaHomepageParserTest.php | 104 ++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 app/Services/Parsers/BelgaHomepageParser.php create mode 100644 app/Services/Parsers/BelgaHomepageParserAdapter.php create mode 100644 tests/Fixtures/belga-pressreleases.json create mode 100644 tests/Unit/Services/Parsers/BelgaHomepageParserTest.php diff --git a/.gitignore b/.gitignore index 58008dd4..a6e08b96 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yarn-error.log /coverage-report* /coverage.xml /.php-cs-fixer.dist.php +/.php-cs-fixer.cache diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php new file mode 100644 index 00000000..90028f81 --- /dev/null +++ b/app/Services/Parsers/BelgaHomepageParser.php @@ -0,0 +1,25 @@ +pluck('id') + ->filter(fn($id) => is_int($id) || (is_string($id) && ctype_digit($id))) + ->map(fn($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) + ->unique() + ->values() + ->toArray(); + } +} diff --git a/app/Services/Parsers/BelgaHomepageParserAdapter.php b/app/Services/Parsers/BelgaHomepageParserAdapter.php new file mode 100644 index 00000000..3726cc00 --- /dev/null +++ b/app/Services/Parsers/BelgaHomepageParserAdapter.php @@ -0,0 +1,34 @@ +language), self::API_URL); + } + + public function getSourceName(): string + { + return 'Belga News Agency'; + } +} diff --git a/tests/Fixtures/belga-pressreleases.json b/tests/Fixtures/belga-pressreleases.json new file mode 100644 index 00000000..4ebba6b9 --- /dev/null +++ b/tests/Fixtures/belga-pressreleases.json @@ -0,0 +1 @@ +{"data":[{"id":35285,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"FIFA cancels plans for World Cup commercialisation after fierce criticism","lead":"FIFA president Gianni Infantino has scrapped the controversial plan to sell shares of the FIFA World Cup to private investors. This was reported by British media, including Sky News and BBC, during the night from Friday to Saturday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:181511382:full?v=6a6c82d8&m=cdodnjha","categories":[{"id":3,"name":"SPORTS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:181511382:full?v=6a6c82d8&m=cdodnjha","publishDate":"2026-08-01T06:24:19"},{"id":35282,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Excluding Spain from Schengen is \"in no way a solution\" for Ceuta, says Belgian FM Prévot","lead":"For minister of Foreign Affairs Maxime Prévot, a temporary exclusion of Spain from the Schengen Area is not a solution. He stated this on Friday in an additional response to the migration crisis in Ceuta, after more than 60,000 people crossed the border between Morocco and the Spanish exclave in recent days.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182478454:full?v=6a6c82d8&m=anpkocdi","categories":[{"id":9,"name":"POLITICS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182478454:full?v=6a6c82d8&m=anpkocdi","publishDate":"2026-07-31T16:59:26"},{"id":35281,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Belgium offers tankers and other vehicles for fire fighting to France","lead":"In the fight against the devastating forest fires that have destroyed thousands of hectares in France over the past few weeks, primarily in the Gironde region, Belgium has expanded its offer of assistance. Our country is ready to send water tankers should France request them, the office of minister of the Interior Bernard Quintin (MR) announced on Friday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182396536:full?v=6a6c82d8&m=fmbijool","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182396536:full?v=6a6c82d8&m=fmbijool","publishDate":"2026-07-31T16:52:04"},{"id":35271,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Brussels Region demands withdrawal of shared scooters and bikes by 1 September","lead":"All shared scooters and bikes in free-floating fleets must be removed from the Brussels streets by no later than 1 September. This was announced by Brussels Mobility on Thursday.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171547:full?v=6a6c82d8&m=gnegeckj","categories":[{"id":9,"name":"POLITICS"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171547:full?v=6a6c82d8&m=gnegeckj","publishDate":"2026-07-31T06:44:12"},{"id":35270,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Canadian intern at SHAPE suspected of espionage for China","lead":"The Belgian authorities suspect a Canadian woman who was interning at NATO's military headquarters in Bergen (SHAPE) of working for China. Two anonymous sources familiar with the matter told Reuters.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171621:full?v=6a6c82d8&m=hkkippeg","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:32171621:full?v=6a6c82d8&m=hkkippeg","publishDate":"2026-07-31T06:37:12"},{"id":35269,"newsroom":{"id":70,"subSourceName":"Belga English","logoUrl":"https://3.ssl.belga.be/bpmedia:brand:205881:thumbnail?v=6a6c82d8&m=oiooaebg","profilePictureUrl":null,"active":true},"content":{"en":{"title":"Hamas confirms disarmament of group and Israeli withdrawal from Gaza","lead":"Representatives of Hamas have confirmed an agreement on the second phase of the ceasefire with Israel during the night from Thursday to Friday. This was reported to the French news agency AFP by two anonymous sources within Hamas following President Donald Trump's announcement of the agreement. The new phase includes, among other things, the disarmament of the extremist Palestinian organisation and the gradual withdrawal of the Israeli military from the Gaza Strip. The Israeli government has not yet responded to the agreement.","coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182283582:full?v=6a6c82d8&m=pgklpfdh","categories":[{"id":10,"name":"GENERAL"}]}},"coverImageUrl":"https://picturepackcdn-h33aaywmsq-ew.a.run.app/belgapicturepack:182283582:full?v=6a6c82d8&m=pgklpfdh","publishDate":"2026-07-31T06:11:52"}],"_links":{"next":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=7&count=6&search=&start=&end=&newsroomId=70&language=EN","prev":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=-5&count=6&search=&start=&end=&newsroomId=70&language=EN","self":"https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN"},"_meta":{"total":14650}} \ No newline at end of file diff --git a/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php new file mode 100644 index 00000000..aeacb5a0 --- /dev/null +++ b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php @@ -0,0 +1,104 @@ +fixture()); + + $this->assertCount(6, $urls); + $this->assertSame('https://www.belganewsagency.eu/press-releases/35285/', $urls[0]); + $this->assertContains('https://www.belganewsagency.eu/press-releases/35269/', $urls); + } + + public function test_deduplicates_urls(): void + { + $json = json_encode(['data' => [ + ['id' => 100], + ['id' => 100], + ['id' => '100'], + ['id' => 101], + ]]); + + $urls = BelgaHomepageParser::extractArticleUrls($json); + + $this->assertSame([ + 'https://www.belganewsagency.eu/press-releases/100/', + 'https://www.belganewsagency.eu/press-releases/101/', + ], $urls); + } + + public function test_returns_empty_array_for_malformed_json(): void + { + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('404 not found')); + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('{"data": [')); + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('')); + } + + public function test_returns_empty_array_when_data_key_missing_or_empty(): void + { + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('{}')); + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('{"data": []}')); + $this->assertSame([], BelgaHomepageParser::extractArticleUrls('{"data": "not-an-array"}')); + } + + public function test_skips_entries_without_usable_id(): void + { + $json = json_encode(['data' => [ + ['id' => 200], + ['id' => null], + ['publishDate' => '2026-08-01T06:24:19'], + ['id' => 'not-numeric'], + ['id' => 201], + ]]); + + $urls = BelgaHomepageParser::extractArticleUrls($json); + + $this->assertSame([ + 'https://www.belganewsagency.eu/press-releases/200/', + 'https://www.belganewsagency.eu/press-releases/201/', + ], $urls); + } + + public function test_adapter_delegates_and_reports_source(): void + { + $adapter = new BelgaHomepageParserAdapter('en'); + + $this->assertSame( + BelgaHomepageParser::extractArticleUrls($this->fixture()), + $adapter->extractArticleUrls($this->fixture()) + ); + + $this->assertTrue($adapter->canParse('https://www.belganewsagency.eu/press-releases/35285/')); + $this->assertTrue($adapter->canParse('https://capi.belga.press/belgapress/api/public/pressreleases')); + $this->assertFalse($adapter->canParse('https://www.vrt.be/vrtnws/en/')); + $this->assertSame('Belga News Agency', $adapter->getSourceName()); + } + + public function test_adapter_uppercases_language_in_api_url(): void + { + $this->assertStringContainsString('language=EN', (new BelgaHomepageParserAdapter('en'))->getHomepageUrl()); + $this->assertStringContainsString('language=NL', (new BelgaHomepageParserAdapter('nl'))->getHomepageUrl()); + } + + public function test_adapter_api_url_pins_load_bearing_query_params(): void + { + $url = (new BelgaHomepageParserAdapter('en'))->getHomepageUrl(); + + $this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $url); + $this->assertStringContainsString('newsroomId=70', $url); + $this->assertStringContainsString('offset=1', $url); + $this->assertStringContainsString('count=6', $url); + } +} -- 2.45.2 From 299ec3151a25ba1cc9e881af6743d8af0feb2a11 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 1 Aug 2026 16:05:45 +0200 Subject: [PATCH 2/6] 115 - Switch Belga provider to website discovery via JSON API --- app/Services/Parsers/BelgaHomepageParser.php | 4 +- .../Parsers/BelgaHomepageParserAdapter.php | 7 +- config/feed.php | 6 +- .../Api/V1/FeedsControllerTest.php | 10 +- tests/Unit/Actions/CreateFeedActionTest.php | 5 +- .../Unit/Services/ArticleFetcherBelgaTest.php | 96 +++++++++++++++++++ .../Parsers/BelgaHomepageParserTest.php | 2 +- 7 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/Services/ArticleFetcherBelgaTest.php diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php index 90028f81..f5374c65 100644 --- a/app/Services/Parsers/BelgaHomepageParser.php +++ b/app/Services/Parsers/BelgaHomepageParser.php @@ -16,8 +16,8 @@ public static function extractArticleUrls(string $json): array return collect($decoded['data']) ->pluck('id') - ->filter(fn($id) => is_int($id) || (is_string($id) && ctype_digit($id))) - ->map(fn($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) + ->filter(fn ($id) => is_int($id) || (is_string($id) && ctype_digit($id))) + ->map(fn ($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) ->unique() ->values() ->toArray(); diff --git a/app/Services/Parsers/BelgaHomepageParserAdapter.php b/app/Services/Parsers/BelgaHomepageParserAdapter.php index 3726cc00..0adb6750 100644 --- a/app/Services/Parsers/BelgaHomepageParserAdapter.php +++ b/app/Services/Parsers/BelgaHomepageParserAdapter.php @@ -6,6 +6,11 @@ class BelgaHomepageParserAdapter implements HomepageParserInterface { + /** + * Must stay in sync with config('feed.providers.belga.languages.en.url'). + * Nothing calls getHomepageUrl() at runtime — the fetcher uses $feed->url, + * seeded from that config value — so drift here would go unnoticed. + */ private const API_URL = 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN'; public function __construct( @@ -24,7 +29,7 @@ public function extractArticleUrls(string $html): array public function getHomepageUrl(): string { - return str_replace('language=EN', 'language=' . strtoupper($this->language), self::API_URL); + return str_replace('language=EN', 'language='.strtoupper($this->language), self::API_URL); } public function getSourceName(): string diff --git a/config/feed.php b/config/feed.php index 4215761c..0d82739e 100644 --- a/config/feed.php +++ b/config/feed.php @@ -2,6 +2,7 @@ use App\Services\Parsers\BelgaArticlePageParser; use App\Services\Parsers\BelgaArticleParser; +use App\Services\Parsers\BelgaHomepageParserAdapter; use App\Services\Parsers\GuardianArticlePageParser; use App\Services\Parsers\GuardianArticleParser; use App\Services\Parsers\VrtArticlePageParser; @@ -41,12 +42,13 @@ 'code' => 'belga', 'name' => 'Belga News Agency', 'description' => 'Belgian national news agency', - 'type' => 'rss', + 'type' => 'website', 'is_active' => true, 'languages' => [ - 'en' => ['url' => 'https://www.belganewsagency.eu/feed'], + 'en' => ['url' => 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6&search=&start=&end=&newsroomId=70&language=EN'], ], 'parsers' => [ + 'homepage' => BelgaHomepageParserAdapter::class, 'article' => BelgaArticleParser::class, 'article_page' => BelgaArticlePageParser::class, ], diff --git a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php index 0acac2b4..5bee7c96 100644 --- a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php @@ -92,22 +92,24 @@ public function test_store_creates_belga_feed_successfully(): void $response = $this->postJson('/api/v1/feeds', $feedData); + $expectedUrl = config('feed.providers.belga.languages.en.url'); + $response->assertStatus(201) ->assertJson([ 'success' => true, 'message' => 'Feed created successfully!', 'data' => [ 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/feed', - 'type' => 'rss', + 'url' => $expectedUrl, + 'type' => 'website', 'is_active' => true, ], ]); $this->assertDatabaseHas('feeds', [ 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/feed', - 'type' => 'rss', + 'url' => $expectedUrl, + 'type' => 'website', ]); } diff --git a/tests/Unit/Actions/CreateFeedActionTest.php b/tests/Unit/Actions/CreateFeedActionTest.php index accb3f54..001b7472 100644 --- a/tests/Unit/Actions/CreateFeedActionTest.php +++ b/tests/Unit/Actions/CreateFeedActionTest.php @@ -42,8 +42,9 @@ public function test_creates_belga_feed_with_correct_url(): void $feed = $this->action->execute('Belga News', 'belga', $language->id); - $this->assertEquals('https://www.belganewsagency.eu/feed', $feed->url); - $this->assertEquals('rss', $feed->type); + $this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $feed->url); + $this->assertStringContainsString('newsroomId=70', $feed->url); + $this->assertEquals('website', $feed->type); $this->assertEquals('belga', $feed->provider); $this->assertNull($feed->description); } diff --git a/tests/Unit/Services/ArticleFetcherBelgaTest.php b/tests/Unit/Services/ArticleFetcherBelgaTest.php new file mode 100644 index 00000000..3b319a5b --- /dev/null +++ b/tests/Unit/Services/ArticleFetcherBelgaTest.php @@ -0,0 +1,96 @@ +create(['short_code' => 'en']); + + return Feed::factory()->create([ + 'type' => 'website', + 'provider' => 'belga', + 'language_id' => $language->id, + 'url' => config('feed.providers.belga.languages.en.url'), + ]); + } + + public function test_creates_articles_from_belga_api_response(): void + { + Http::fake(['*' => Http::response($this->apiResponse(), 200)]); + + $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); + + $this->assertCount(6, $result); + $this->assertDatabaseHas('articles', [ + 'url' => 'https://www.belganewsagency.eu/press-releases/35285/', + ]); + $this->assertDatabaseHas('articles', [ + 'url' => 'https://www.belganewsagency.eu/press-releases/35269/', + ]); + } + + public function test_associates_created_articles_with_the_feed(): void + { + Http::fake(['*' => Http::response($this->apiResponse(), 200)]); + + $feed = $this->belgaFeed(); + + $this->createArticleFetcher()->getArticlesFromFeed($feed); + + $this->assertDatabaseHas('articles', [ + 'url' => 'https://www.belganewsagency.eu/press-releases/35285/', + 'feed_id' => $feed->id, + ]); + } + + public function test_returns_empty_collection_when_api_returns_no_articles(): void + { + // A well-formed envelope with nothing in it is legitimate (quiet day), + // and must be a no-op rather than an error. + Http::fake(['*' => Http::response('{"data":[],"_meta":{"total":0}}', 200)]); + + $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); + + $this->assertEmpty($result); + $this->assertDatabaseCount('articles', 0); + } + + public function test_returns_empty_collection_when_api_returns_an_error_page(): void + { + // The failure mode that caused #115: a 404 HTML body reaching the parser. + Http::fake(['*' => Http::response('404 Not Found', 200)]); + + $result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); + + $this->assertEmpty($result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php index aeacb5a0..04be29cf 100644 --- a/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php +++ b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php @@ -10,7 +10,7 @@ class BelgaHomepageParserTest extends TestCase { private function fixture(): string { - return file_get_contents(__DIR__ . '/../../../Fixtures/belga-pressreleases.json'); + return file_get_contents(__DIR__.'/../../../Fixtures/belga-pressreleases.json'); } public function test_extracts_article_urls_from_api_json(): void -- 2.45.2 From 253700312eab34c8853dc3fd73615ec1d4b42a6d Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 1 Aug 2026 17:13:32 +0200 Subject: [PATCH 3/6] 117 - Decode HTML entities in extracted thumbnail URLs --- .../Parsers/BelgaArticlePageParser.php | 4 +- .../Parsers/GuardianArticlePageParser.php | 4 +- app/Services/Parsers/VrtArticlePageParser.php | 4 +- .../Parsers/BelgaArticlePageParserTest.php | 30 ++++++++ .../Parsers/GuardianArticlePageParserTest.php | 20 +++++ .../Parsers/VrtArticlePageParserTest.php | 75 +++++++++++++++++++ 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Services/Parsers/VrtArticlePageParserTest.php diff --git a/app/Services/Parsers/BelgaArticlePageParser.php b/app/Services/Parsers/BelgaArticlePageParser.php index 3c1fdc44..955bd51a 100644 --- a/app/Services/Parsers/BelgaArticlePageParser.php +++ b/app/Services/Parsers/BelgaArticlePageParser.php @@ -110,12 +110,12 @@ public static function extractThumbnail(string $html): ?string { // Try OpenGraph image first if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) { - return $matches[1]; + return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8'); } return null; diff --git a/app/Services/Parsers/GuardianArticlePageParser.php b/app/Services/Parsers/GuardianArticlePageParser.php index 4d462115..09c7c4fa 100644 --- a/app/Services/Parsers/GuardianArticlePageParser.php +++ b/app/Services/Parsers/GuardianArticlePageParser.php @@ -68,12 +68,12 @@ public static function extractThumbnail(string $html): ?string { // Try OpenGraph image first if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) { - return $matches[1]; + return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8'); } return null; diff --git a/app/Services/Parsers/VrtArticlePageParser.php b/app/Services/Parsers/VrtArticlePageParser.php index e312bb6f..095c7389 100644 --- a/app/Services/Parsers/VrtArticlePageParser.php +++ b/app/Services/Parsers/VrtArticlePageParser.php @@ -67,11 +67,11 @@ public static function extractFullArticle(string $html): ?string public static function extractThumbnail(string $html): ?string { if (preg_match('/]+src="([^"]+)"/i', $html, $matches)) { - return $matches[1]; + return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8'); } return null; diff --git a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php index 83a78baf..4774419d 100644 --- a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php +++ b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php @@ -242,6 +242,36 @@ public function test_extract_thumbnail_prefers_og_image(): void $this->assertEquals('https://example.com/og-image.jpg', $thumbnail); } + public function test_extract_thumbnail_decodes_html_entities_in_og_image(): void + { + // Real Belga CDN URLs carry query params, so the & in the raw HTML + // must be decoded or the URL Lemmy receives is malformed. See #117. + $html = ''; + + $thumbnail = BelgaArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://cdn.example.com/img:12345:full?v=6a6c828f&m=cdodnjha', $thumbnail); + $this->assertStringNotContainsString('&', (string) $thumbnail); + } + + public function test_extract_thumbnail_decodes_html_entities_in_img_tag(): void + { + $html = 'test'; + + $thumbnail = BelgaArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://cdn.example.com/pic.png?a=1&b=2', $thumbnail); + } + + public function test_extract_data_returns_decoded_thumbnail(): void + { + $html = ''; + + $data = BelgaArticlePageParser::extractData($html); + + $this->assertEquals('https://cdn.example.com/x.jpg?v=1&m=2', $data['thumbnail']); + } + public function test_extract_thumbnail_returns_null_when_not_found(): void { $html = '
No images here
'; diff --git a/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php b/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php index 1e233706..d280a9f8 100644 --- a/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php +++ b/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php @@ -282,4 +282,24 @@ public function test_extract_full_article_with_realistic_guardian_html(): void $this->assertStringContainsString("\n\n", $fullArticle); $this->assertStringNotContainsString('', $fullArticle); } + + public function test_extract_thumbnail_decodes_html_entities(): void + { + // Entity-encoded ampersands make the URL unusable downstream. See #117. + $html = ''; + + $thumbnail = GuardianArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://i.guim.co.uk/img/media/abc.jpg?width=1200&quality=85', $thumbnail); + $this->assertStringNotContainsString('&', (string) $thumbnail); + } + + public function test_extract_thumbnail_decodes_html_entities_in_img_tag(): void + { + $html = 'test'; + + $thumbnail = GuardianArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://i.guim.co.uk/img/pic.png?a=1&b=2', $thumbnail); + } } diff --git a/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php b/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php new file mode 100644 index 00000000..6f3c6a61 --- /dev/null +++ b/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php @@ -0,0 +1,75 @@ +'; + + $this->assertEquals( + 'https://images.vrt.be/image.jpg', + VrtArticlePageParser::extractThumbnail($html) + ); + } + + public function test_extract_thumbnail_falls_back_to_img_tag(): void + { + $html = 'test'; + + $this->assertEquals( + 'https://images.vrt.be/body.png', + VrtArticlePageParser::extractThumbnail($html) + ); + } + + public function test_extract_thumbnail_prefers_og_image(): void + { + $html = '' + .'test'; + + $this->assertEquals( + 'https://images.vrt.be/og.jpg', + VrtArticlePageParser::extractThumbnail($html) + ); + } + + public function test_extract_thumbnail_returns_null_when_not_found(): void + { + $this->assertNull(VrtArticlePageParser::extractThumbnail('No images')); + } + + public function test_extract_thumbnail_decodes_html_entities(): void + { + // Entity-encoded ampersands make the URL unusable downstream. See #117. + $html = ''; + + $thumbnail = VrtArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://images.vrt.be/orig.jpg?crop=1&w=1200', $thumbnail); + $this->assertStringNotContainsString('&', (string) $thumbnail); + } + + public function test_extract_thumbnail_decodes_html_entities_in_img_tag(): void + { + $html = 'test'; + + $this->assertEquals( + 'https://images.vrt.be/pic.png?a=1&b=2', + VrtArticlePageParser::extractThumbnail($html) + ); + } + + public function test_extract_data_returns_decoded_thumbnail(): void + { + $html = ''; + + $data = VrtArticlePageParser::extractData($html); + + $this->assertEquals('https://images.vrt.be/x.jpg?v=1&m=2', $data['thumbnail']); + } +} -- 2.45.2 From 3bded659d2ff8b298813a1642d75440589ef6cad Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 1 Aug 2026 23:43:52 +0200 Subject: [PATCH 4/6] 120 - Hardcode app display name instead of deriving it from APP_NAME --- config/app.php | 2 +- resources/views/layouts/app.blade.php | 2 +- resources/views/layouts/guest.blade.php | 2 +- resources/views/layouts/onboarding.blade.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/app.php b/config/app.php index 324b513a..e924bd75 100644 --- a/config/app.php +++ b/config/app.php @@ -13,7 +13,7 @@ | */ - 'name' => env('APP_NAME', 'Laravel'), + 'name' => 'Fedi Feed Router', /* |-------------------------------------------------------------------------- diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 30d77eda..6239a493 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -5,7 +5,7 @@ - {{ config('app.name', 'FFR') }} + {{ config('app.name') }} diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php index 469c4c32..9bef59e2 100644 --- a/resources/views/layouts/guest.blade.php +++ b/resources/views/layouts/guest.blade.php @@ -5,7 +5,7 @@ - {{ config('app.name', 'FFR') }} + {{ config('app.name') }} diff --git a/resources/views/layouts/onboarding.blade.php b/resources/views/layouts/onboarding.blade.php index 875e9eb0..b43f53c8 100644 --- a/resources/views/layouts/onboarding.blade.php +++ b/resources/views/layouts/onboarding.blade.php @@ -5,7 +5,7 @@ - {{ config('app.name', 'FFR') }} - Setup + {{ config('app.name') }} - Setup -- 2.45.2 From ac1b461e41969aac658a5b1ea0ef3194a5800934 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 2 Aug 2026 00:08:14 +0200 Subject: [PATCH 5/6] 115 - Satisfy PHPStan on Belga parser and fixture helpers --- app/Services/Parsers/BelgaHomepageParser.php | 7 ++++-- .../Unit/Services/ArticleFetcherBelgaTest.php | 6 ++++- .../Parsers/BelgaHomepageParserTest.php | 22 ++++++++++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php index f5374c65..cd8e112a 100644 --- a/app/Services/Parsers/BelgaHomepageParser.php +++ b/app/Services/Parsers/BelgaHomepageParser.php @@ -6,6 +6,9 @@ class BelgaHomepageParser { private const ARTICLE_URL_TEMPLATE = 'https://www.belganewsagency.eu/press-releases/%s/'; + /** + * @return array + */ public static function extractArticleUrls(string $json): array { $decoded = json_decode($json, true); @@ -16,8 +19,8 @@ public static function extractArticleUrls(string $json): array return collect($decoded['data']) ->pluck('id') - ->filter(fn ($id) => is_int($id) || (is_string($id) && ctype_digit($id))) - ->map(fn ($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) + ->filter(fn($id) => is_int($id) || (is_string($id) && ctype_digit($id))) + ->map(fn($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) ->unique() ->values() ->toArray(); diff --git a/tests/Unit/Services/ArticleFetcherBelgaTest.php b/tests/Unit/Services/ArticleFetcherBelgaTest.php index 3b319a5b..8442dc05 100644 --- a/tests/Unit/Services/ArticleFetcherBelgaTest.php +++ b/tests/Unit/Services/ArticleFetcherBelgaTest.php @@ -22,7 +22,11 @@ class ArticleFetcherBelgaTest extends TestCase private function apiResponse(): string { - return file_get_contents(__DIR__.'/../../Fixtures/belga-pressreleases.json'); + $contents = file_get_contents(__DIR__.'/../../Fixtures/belga-pressreleases.json'); + + $this->assertNotFalse($contents, 'Belga fixture could not be read.'); + + return $contents; } private function belgaFeed(): Feed diff --git a/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php index 04be29cf..7fd3b933 100644 --- a/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php +++ b/tests/Unit/Services/Parsers/BelgaHomepageParserTest.php @@ -10,7 +10,19 @@ class BelgaHomepageParserTest extends TestCase { private function fixture(): string { - return file_get_contents(__DIR__.'/../../../Fixtures/belga-pressreleases.json'); + $contents = file_get_contents(__DIR__.'/../../../Fixtures/belga-pressreleases.json'); + + $this->assertNotFalse($contents, 'Belga fixture could not be read.'); + + return $contents; + } + + /** + * @param array> $data + */ + private function jsonWith(array $data): string + { + return (string) json_encode(['data' => $data]); } public function test_extracts_article_urls_from_api_json(): void @@ -24,12 +36,12 @@ public function test_extracts_article_urls_from_api_json(): void public function test_deduplicates_urls(): void { - $json = json_encode(['data' => [ + $json = $this->jsonWith([ ['id' => 100], ['id' => 100], ['id' => '100'], ['id' => 101], - ]]); + ]); $urls = BelgaHomepageParser::extractArticleUrls($json); @@ -55,13 +67,13 @@ public function test_returns_empty_array_when_data_key_missing_or_empty(): void public function test_skips_entries_without_usable_id(): void { - $json = json_encode(['data' => [ + $json = $this->jsonWith([ ['id' => 200], ['id' => null], ['publishDate' => '2026-08-01T06:24:19'], ['id' => 'not-numeric'], ['id' => 201], - ]]); + ]); $urls = BelgaHomepageParser::extractArticleUrls($json); -- 2.45.2 From 7d745b036a89c02abb736fff383597b5e0f2a5d5 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 2 Aug 2026 02:04:23 +0200 Subject: [PATCH 6/6] 115 - Satisfy PHPStan on Belga parser and fixture helpers --- app/Services/Parsers/BelgaHomepageParser.php | 4 ++-- pint.json | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 pint.json diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php index cd8e112a..b783620a 100644 --- a/app/Services/Parsers/BelgaHomepageParser.php +++ b/app/Services/Parsers/BelgaHomepageParser.php @@ -19,8 +19,8 @@ public static function extractArticleUrls(string $json): array return collect($decoded['data']) ->pluck('id') - ->filter(fn($id) => is_int($id) || (is_string($id) && ctype_digit($id))) - ->map(fn($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) + ->filter(fn ($id) => is_int($id) || (is_string($id) && ctype_digit($id))) + ->map(fn ($id) => sprintf(self::ARTICLE_URL_TEMPLATE, $id)) ->unique() ->values() ->toArray(); diff --git a/pint.json b/pint.json new file mode 100644 index 00000000..93061b6b --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} -- 2.45.2