Compare commits

..

7 commits
v1.4.1 ... main

Author SHA1 Message Date
7270e3311c 157 - Add Changelog
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m49s
2026-08-16 23:57:38 +02:00
c640d7a33a 150 - Add dev-test nix command
All checks were successful
Build and Push Docker Image / build (push) Successful in 5m28s
2026-08-16 23:37:50 +02:00
945a27b54f Merge pull request 'release/v1.4.2' (#159) from release/v1.4.2 into main
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #159
2026-08-16 23:36:23 +02:00
aa00950543 157 - Backfill route articles when a route is activated
All checks were successful
CI / ci (push) Successful in 2m43s
CI / ci (pull_request) Successful in 1m58s
2026-08-16 23:18:09 +02:00
ba8513c256 158 - Fix Belga feed offset=1 skipping newest article
All checks were successful
CI / ci (push) Successful in 2m12s
2026-08-16 22:53:52 +02:00
79e70ada1b 150 - Bump the README compose example to v1.4.1
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m47s
2026-08-15 12:04:26 +02:00
e78af42297 150 - Add the v1.4.1 changelog section
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2026-08-15 12:02:30 +02:00
17 changed files with 700 additions and 28 deletions

View file

@ -2,6 +2,31 @@ # Changelog
All notable changes to this project will be documented in this file.
## [1.4.2] - 2026-08-16
### Fixed
- Fix Belga discovery always dropping the newest press release (#158)
- The Belga API `offset` parameter is a 0-based item index, so the configured `offset=1` skipped the newest article on every fetch. A data migration repoints the existing Belga feed to `offset=0`.
- Fix articles validated before their feed had an active route never becoming routable (#157)
- Route articles are now backfilled when a route is created or re-activated, using the article content stored at validation time — no re-fetch.
## [1.4.1] - 2026-08-15
### Fixed
- Fix migrations being unable to run since v1.3.7, which left the dashboard and article approval returning a 500 and publishing failing (#150)
- The migration converting channel community ids logged in to Lemmy to resolve them, once per channel. Lemmy rate-limits authentication, so it failed and blocked the eight migrations behind it.
- **Upgrading deletes channels whose community id was never converted, along with their routes, keywords and publication history.** Recreate them from the Channels page; the community is validated against the instance at creation.
- Fix tests failing at random from factories generating duplicate values for columns with unique constraints (#152)
- Fix the daily publish cap tests depending on the time of day they ran (#152)
### Changed
- CI runs in about two minutes instead of fifteen to thirty (#147)
- PHP is now baked into a prebuilt image rather than installed on every run, coverage is no longer collected since nothing consumed it, and the Composer cache path was wrong so no packages were ever cached.
- Give the CI runner a fallback DNS resolver, so a dropped lookup no longer times out an entire run (#153)
## [1.4.0] - 2026-08-14
### Added

View file

@ -71,7 +71,7 @@ ### docker-compose.yml
```yaml
services:
app:
image: forge.lvl0.xyz/lvl0/fedi-feed-router:v1.4.0
image: forge.lvl0.xyz/lvl0/fedi-feed-router:v1.4.1
container_name: ffr_app
restart: always
ports:

View file

@ -0,0 +1,35 @@
<?php
namespace App\Actions;
use App\Models\Article;
use App\Models\Route;
class BackfillRouteArticlesAction
{
public function __construct(
private CreateRouteArticlesAction $createRouteArticles,
) {}
public function execute(Route $route): void
{
if (! $route->is_active) {
return;
}
// Articles already validated with content, but never routed to this
// route. Uses the stored content rather than re-fetching, so a large
// backlog cannot trigger a network storm (#157).
Article::query()
->where('feed_id', $route->feed_id)
->whereNotNull('content')
->whereDoesntHave('routeArticles', function ($query) use ($route) {
$query->where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id);
})
->lazy()
->each(function (Article $article) use ($route) {
$this->createRouteArticles->createForRoute($article, $route, (string) $article->content);
});
}
}

View file

@ -2,6 +2,7 @@
namespace App\Actions;
use App\Events\RouteActivated;
use App\Models\Route;
class CreateRouteAction
@ -12,7 +13,7 @@ class CreateRouteAction
*/
public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route
{
return Route::firstOrCreate(
$route = Route::firstOrCreate(
[
'feed_id' => $feedId,
'platform_channel_id' => $platformChannelId,
@ -22,5 +23,11 @@ public function execute(int $feedId, int $platformChannelId, int $priority = 0,
'is_active' => $isActive,
]
);
if ($route->wasRecentlyCreated && $route->is_active) {
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
}
return $route;
}
}

View file

@ -18,36 +18,38 @@ public function execute(Article $article, string $content): void
->where('is_active', true)
->get();
// Batch-load all active keywords for this feed, grouped by channel
$keywordsByChannel = Keyword::where('feed_id', $article->feed_id)
foreach ($activeRoutes as $route) {
$this->createForRoute($article, $route, $content);
}
}
public function createForRoute(Article $article, Route $route, string $content): void
{
$routeKeywords = Keyword::where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->where('is_active', true)
->get()
->groupBy('platform_channel_id');
->get();
// Match keywords against full article content, title, and description
$searchableContent = $content.' '.$article->title.' '.$article->description;
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
foreach ($activeRoutes as $route) {
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect());
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$status = ApprovalStatusEnum::APPROVED;
}
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$status = ApprovalStatusEnum::APPROVED;
}
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
}
/**

View file

@ -0,0 +1,17 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RouteActivated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $feedId,
public int $platformChannelId,
) {}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Listeners;
use App\Actions\BackfillRouteArticlesAction;
use App\Events\RouteActivated;
use App\Models\Route;
use Illuminate\Contracts\Queue\ShouldQueue;
class BackfillRouteArticlesListener implements ShouldQueue
{
public string $queue = 'default';
public function __construct(
private BackfillRouteArticlesAction $backfillRouteArticles,
) {}
public function handle(RouteActivated $event): void
{
$route = Route::query()
->where('feed_id', $event->feedId)
->where('platform_channel_id', $event->platformChannelId)
->first();
if ($route === null || ! $route->is_active) {
return;
}
$this->backfillRouteArticles->execute($route);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Events\RouteActivated;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
@ -72,6 +73,8 @@ public function createRoute(): void
'is_active' => true,
]);
RouteActivated::dispatch($this->newFeedId, $this->newChannelId);
$this->closeCreateModal();
}
@ -129,6 +132,10 @@ public function toggle(int $feedId, int $channelId): void
$route->is_active = ! $route->is_active;
$route->save();
if ($route->is_active) {
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
}
}
public function delete(int $feedId, int $channelId): void

View file

@ -22,6 +22,7 @@
* @property string $url
* @property string $title
* @property string|null $description
* @property string|null $content
* @property Carbon|null $validated_at
* @property Carbon $created_at
* @property Carbon $updated_at

View file

@ -45,7 +45,8 @@
'type' => 'website',
'is_active' => true,
'languages' => [
'en' => ['url' => 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=50&search=&start=&end=&newsroomId=70&language=EN'],
// offset is a 0-based item index; offset=1 skips the newest release (#158).
'en' => ['url' => 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=0&count=50&search=&start=&end=&newsroomId=70&language=EN'],
],
'parsers' => [
'homepage' => BelgaHomepageParserAdapter::class,

View file

@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The Belga API offset parameter is 0-based, so the previous url's offset=1
* silently skipped the newest press release on every fetch (#158). The url in
* config/feed.php moved to offset=0; this migration updates the stored feed
* row to match.
*
* System feeds are maintained by the platform, not the end user, so their
* stored url is updated through migrations rather than the UI.
*
* Keyed on `provider`, never on the previous url: CreateFeedAction looks feeds
* up by url via firstOrCreate, so leaving the row on the old url while config
* points elsewhere would insert a second belga row on the next seed instead of
* updating the existing one.
*
* `feeds.url` is unique while `feeds.provider` is not, so a blanket update
* across several belga rows would collide. Adopt the row already on the target
* url (or the oldest belga row when none is) and deactivate the rest.
*
* Superseded rows are deactivated rather than deleted routes.feed_id
* cascades on delete, so removing a feed would take its routing rules with it.
*/
return new class extends Migration
{
public function up(): void
{
$url = config('feed.providers.belga.languages.en.url');
if (! is_string($url) || $url === '') {
return;
}
$keepId = DB::table('feeds')->where('provider', 'belga')->where('url', $url)->min('id')
?? DB::table('feeds')->where('provider', 'belga')->min('id');
if ($keepId === null) {
return;
}
DB::table('feeds')
->where('provider', 'belga')
->where('id', '!=', $keepId)
->update([
'is_active' => false,
'updated_at' => now(),
]);
DB::table('feeds')
->where('id', $keepId)
->update([
'url' => $url,
'updated_at' => now(),
]);
}
public function down(): void
{
// No rollback: restoring offset=1 would reinstate a feed that silently
// drops the newest press release.
}
};

View file

@ -91,6 +91,10 @@ pkgs.mkShell {
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
}
dev-test() {
podman-compose -f $COMPOSE_FILE exec -T app php -d memory_limit=512M vendor/bin/phpunit "$@"
}
# ===================
# BUILD COMMANDS
# ===================
@ -139,6 +143,7 @@ pkgs.mkShell {
echo " dev-logs-db Tail database logs"
echo " dev-shell Shell into app container"
echo " dev-artisan <cmd> Run artisan command"
echo " dev-test [path] Run PHPUnit suite (CI invocation)"
echo " base-build Build and push base image"
echo ""
echo "Services:"

View file

@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Actions\BackfillRouteArticlesAction;
use App\Actions\CreateRouteAction;
use App\Actions\CreateRouteArticlesAction;
use App\Events\RouteActivated;
use App\Listeners\BackfillRouteArticlesListener;
use App\Livewire\Routes;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
use Tests\TestCase;
class BackfillRouteArticlesTest extends TestCase
{
use RefreshDatabase;
private function listener(): BackfillRouteArticlesListener
{
return new BackfillRouteArticlesListener(new BackfillRouteArticlesAction(new CreateRouteArticlesAction));
}
private function strandedArticle(Route $route): Article
{
return Article::factory()->create([
'feed_id' => $route->feed_id,
'content' => 'Some article content',
]);
}
private function makeRoute(bool $isActive): Route
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
return Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'priority' => 50,
'is_active' => $isActive,
]);
}
public function test_listener_backfills_stranded_articles(): void
{
$route = $this->makeRoute(true);
$article = $this->strandedArticle($route);
$this->listener()->handle(new RouteActivated($route->feed_id, $route->platform_channel_id));
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_listener_does_nothing_when_the_route_is_missing(): void
{
$this->listener()->handle(new RouteActivated(99999, 99999));
$this->assertSame(0, RouteArticle::count());
}
public function test_listener_does_nothing_when_the_route_is_inactive(): void
{
$route = $this->makeRoute(false);
$this->strandedArticle($route);
$this->listener()->handle(new RouteActivated($route->feed_id, $route->platform_channel_id));
$this->assertSame(0, RouteArticle::count());
}
public function test_creating_a_route_backfills_articles_that_were_validated_with_no_route(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'content' => 'Some article content',
]);
(new CreateRouteAction)->execute($feed->id, $channel->id);
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_toggling_a_route_inactive_then_active_backfills(): void
{
$route = $this->makeRoute(false);
$article = $this->strandedArticle($route);
Livewire::test(Routes::class)
->call('toggle', $route->feed_id, $route->platform_channel_id);
$this->assertTrue($route->fresh()->is_active);
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_create_route_action_dispatches_route_activated(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id);
Event::assertDispatched(
RouteActivated::class,
fn (RouteActivated $event) => $event->feedId === $feed->id && $event->platformChannelId === $channel->id
);
}
public function test_create_route_action_does_not_dispatch_for_an_existing_route(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id);
(new CreateRouteAction)->execute($feed->id, $channel->id);
Event::assertDispatchedTimes(RouteActivated::class, 1);
}
public function test_create_route_action_does_not_dispatch_for_an_inactive_route(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id, 0, false);
Event::assertNotDispatched(RouteActivated::class);
}
}

View file

@ -6,6 +6,7 @@
use App\Events\ActivityLogged;
use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched;
use App\Events\RouteActivated;
use App\Events\RouteArticleApproved;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event;
@ -25,6 +26,7 @@ public static function eventProvider(): array
'ActivityLogged' => [ActivityLogged::class],
'ExceptionOccurred' => [ExceptionOccurred::class],
'NewArticleFetched' => [NewArticleFetched::class],
'RouteActivated' => [RouteActivated::class],
'RouteArticleApproved' => [RouteArticleApproved::class],
];
}

View file

@ -0,0 +1,176 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use stdClass;
use Tests\TestCase;
/**
* The migration has already run by the time RefreshDatabase hands over, so
* these tests seed a pre-migration state and invoke up() against it directly.
*/
class FixBelgaFeedOffsetMigrationTest extends TestCase
{
use RefreshDatabase;
private const OFFSET_ONE_URL = 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=50&search=&start=&end=&newsroomId=70&language=EN';
private function runMigration(): void
{
$migration = require database_path('migrations/2024_01_01_000024_fix_belga_feed_offset.php');
$migration->up();
}
private function findFeed(int $id): stdClass
{
$feed = DB::table('feeds')->find($id);
$this->assertInstanceOf(stdClass::class, $feed);
return $feed;
}
private function targetUrl(): string
{
$url = config('feed.providers.belga.languages.en.url');
$this->assertIsString($url);
return $url;
}
private function seedFeed(string $url, bool $isActive = true, string $provider = 'belga', string $type = 'website'): int
{
return DB::table('feeds')->insertGetId([
'name' => 'Belga',
'provider' => $provider,
'type' => $type,
'url' => $url,
'is_active' => $isActive,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function test_moves_a_feed_still_on_the_offset_one_url(): void
{
DB::table('feeds')->delete();
$id = $this->seedFeed(self::OFFSET_ONE_URL);
$this->runMigration();
$feed = $this->findFeed($id);
$this->assertSame($this->targetUrl(), $feed->url);
$this->assertSame('website', $feed->type);
$this->assertTrue((bool) $feed->is_active);
}
public function test_is_idempotent_when_the_feed_is_already_on_the_target_url(): void
{
DB::table('feeds')->delete();
$id = $this->seedFeed($this->targetUrl());
$this->runMigration();
$this->runMigration();
$this->assertSame(1, DB::table('feeds')->where('provider', 'belga')->count());
$feed = $this->findFeed($id);
$this->assertSame($this->targetUrl(), $feed->url);
$this->assertTrue((bool) $feed->is_active);
}
public function test_deactivates_superseded_rows_instead_of_colliding_on_the_unique_url(): void
{
DB::table('feeds')->delete();
$staleId = $this->seedFeed(self::OFFSET_ONE_URL);
$currentId = $this->seedFeed($this->targetUrl());
$this->runMigration();
// Both rows survive: routes.feed_id cascades on delete, so a superseded
// feed is deactivated rather than removed.
$this->assertSame(2, DB::table('feeds')->where('provider', 'belga')->count());
$current = $this->findFeed($currentId);
$this->assertTrue((bool) $current->is_active);
$this->assertSame($this->targetUrl(), $current->url);
$stale = $this->findFeed($staleId);
$this->assertFalse((bool) $stale->is_active);
}
public function test_adopts_the_oldest_row_when_none_is_on_the_target_url(): void
{
DB::table('feeds')->delete();
$oldestId = $this->seedFeed(self::OFFSET_ONE_URL);
$newerId = $this->seedFeed('https://capi.belga.press/belgapress/api/public/pressreleases?offset=1&count=6');
$this->runMigration();
// Neither row matches config, so the migration falls back to the lowest
// id — the oldest row, which is the one routes are most likely tied to.
$oldest = $this->findFeed($oldestId);
$this->assertSame($this->targetUrl(), $oldest->url);
$this->assertTrue((bool) $oldest->is_active);
$this->assertFalse((bool) $this->findFeed($newerId)->is_active);
}
public function test_leaves_other_providers_untouched(): void
{
DB::table('feeds')->delete();
$vrtId = $this->seedFeed('https://www.vrt.be/vrtnws/nl/', true, 'vrt');
// Seed a belga row too, so the migration actually runs its updates
// rather than bailing out at the "no belga feed" guard.
$this->seedFeed(self::OFFSET_ONE_URL);
$this->runMigration();
$vrt = $this->findFeed($vrtId);
$this->assertSame('https://www.vrt.be/vrtnws/nl/', $vrt->url);
$this->assertTrue((bool) $vrt->is_active);
}
public function test_down_is_a_deliberate_no_op(): void
{
DB::table('feeds')->delete();
$id = $this->seedFeed($this->targetUrl());
$migration = require database_path('migrations/2024_01_01_000024_fix_belga_feed_offset.php');
$migration->down();
// Rolling back must not restore the offset=1 url that drops the newest
// press release.
$feed = $this->findFeed($id);
$this->assertSame($this->targetUrl(), $feed->url);
$this->assertTrue((bool) $feed->is_active);
}
public function test_does_nothing_when_no_belga_feed_exists(): void
{
DB::table('feeds')->delete();
$this->runMigration();
$this->assertSame(0, DB::table('feeds')->count());
}
public function test_does_nothing_when_the_configured_url_is_missing(): void
{
DB::table('feeds')->delete();
config(['feed.providers.belga.languages.en.url' => '']);
$id = $this->seedFeed(self::OFFSET_ONE_URL);
$this->runMigration();
$feed = $this->findFeed($id);
$this->assertSame(self::OFFSET_ONE_URL, $feed->url);
}
}

View file

@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\BackfillRouteArticlesAction;
use App\Actions\CreateRouteArticlesAction;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BackfillRouteArticlesActionTest extends TestCase
{
use RefreshDatabase;
private function action(): BackfillRouteArticlesAction
{
return new BackfillRouteArticlesAction(new CreateRouteArticlesAction);
}
private function route(bool $isActive = true): Route
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
return Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'priority' => 50,
'is_active' => $isActive,
]);
}
private function strandedArticle(Route $route): Article
{
return Article::factory()->create([
'feed_id' => $route->feed_id,
'title' => 'A title',
'description' => 'A description',
'content' => 'Some article content',
]);
}
public function test_it_backfills_articles_missing_a_route_article(): void
{
$route = $this->route();
$article = $this->strandedArticle($route);
$this->action()->execute($route);
$this->assertDatabaseHas('route_articles', [
'article_id' => $article->id,
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
]);
}
public function test_it_is_idempotent(): void
{
$route = $this->route();
$this->strandedArticle($route);
$this->action()->execute($route);
$this->action()->execute($route);
$this->assertSame(1, RouteArticle::count());
}
public function test_it_skips_articles_already_routed_to_this_route(): void
{
$route = $this->route();
$article = $this->strandedArticle($route);
RouteArticle::create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
'approval_status' => ApprovalStatusEnum::APPROVED,
]);
$this->action()->execute($route);
$this->assertSame(1, RouteArticle::count());
$this->assertSame(ApprovalStatusEnum::APPROVED, RouteArticle::first()->approval_status);
}
public function test_it_skips_articles_without_stored_content(): void
{
$route = $this->route();
Article::factory()->create([
'feed_id' => $route->feed_id,
'content' => null,
]);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::count());
}
public function test_it_does_nothing_for_an_inactive_route(): void
{
$route = $this->route(isActive: false);
$this->strandedArticle($route);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::count());
}
public function test_it_does_not_touch_articles_in_other_feeds(): void
{
$route = $this->route();
$this->strandedArticle($route);
$otherFeed = Feed::factory()->create();
$otherArticle = Article::factory()->create([
'feed_id' => $otherFeed->id,
'content' => 'Other content',
]);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::where('article_id', $otherArticle->id)->count());
}
public function test_keyword_matching_uses_the_stored_content_without_refetching(): void
{
Setting::setBool('enable_publishing_approvals', true);
$route = $this->route();
Article::factory()->create([
'feed_id' => $route->feed_id,
'title' => 'A title',
'description' => 'A description',
'content' => 'news from Brussels today',
]);
Keyword::create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'keyword' => 'brussels',
'is_active' => true,
]);
$this->action()->execute($route);
$this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status);
}
}

View file

@ -114,7 +114,8 @@ public function test_adapter_api_url_pins_load_bearing_query_params(): void
$this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $url);
$this->assertStringContainsString('newsroomId=70', $url);
$this->assertStringContainsString('offset=1', $url);
// 0-based item index: offset=1 skipped the newest release entirely (#158).
$this->assertStringContainsString('offset=0&', $url);
$this->assertStringContainsString('count=50', $url);
$this->assertStringContainsString('language=EN', $url);
}