Compare commits
No commits in common. "main" and "v1.4.0" have entirely different histories.
29 changed files with 124 additions and 915 deletions
|
|
@ -4,20 +4,27 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: ['release/*']
|
branches: ['release/*']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, 'release/*']
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: forge.lvl0.xyz/lvl0/fedi-feed-router-ci:php8.3-3
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up PHP
|
||||||
|
uses: https://github.com/shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: pdo_sqlite, mbstring, xml, dom
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: https://data.forgejo.org/actions/cache@v4
|
uses: https://data.forgejo.org/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/composer
|
path: ~/.composer/cache
|
||||||
key: composer-${{ hashFiles('composer.lock') }}
|
key: composer-${{ hashFiles('composer.lock') }}
|
||||||
restore-keys: composer-
|
restore-keys: composer-
|
||||||
|
|
||||||
|
|
@ -34,4 +41,4 @@ jobs:
|
||||||
run: vendor/bin/phpstan analyse --memory-limit=1G
|
run: vendor/bin/phpstan analyse --memory-limit=1G
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
name: Build and Push Base Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'docker/build/**'
|
|
||||||
- '.forgejo/workflows/images.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
images:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: fedi-feed-router-base
|
|
||||||
file: docker/build/Dockerfile.base
|
|
||||||
version: php8.3-1
|
|
||||||
- name: fedi-feed-router-ci
|
|
||||||
file: docker/build/Dockerfile.ci
|
|
||||||
version: php8.3-3
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Forgejo Registry
|
|
||||||
uses: https://data.forgejo.org/docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: forge.lvl0.xyz
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.file }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ matrix.version }}
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:latest
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ github.sha }}
|
|
||||||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -2,31 +2,6 @@ # Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [1.4.0] - 2026-08-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ ### docker-compose.yml
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: forge.lvl0.xyz/lvl0/fedi-feed-router:v1.4.1
|
image: forge.lvl0.xyz/lvl0/fedi-feed-router:v1.4.0
|
||||||
container_name: ffr_app
|
container_name: ffr_app
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Actions;
|
namespace App\Actions;
|
||||||
|
|
||||||
use App\Events\RouteActivated;
|
|
||||||
use App\Models\Route;
|
use App\Models\Route;
|
||||||
|
|
||||||
class CreateRouteAction
|
class CreateRouteAction
|
||||||
|
|
@ -13,7 +12,7 @@ class CreateRouteAction
|
||||||
*/
|
*/
|
||||||
public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route
|
public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route
|
||||||
{
|
{
|
||||||
$route = Route::firstOrCreate(
|
return Route::firstOrCreate(
|
||||||
[
|
[
|
||||||
'feed_id' => $feedId,
|
'feed_id' => $feedId,
|
||||||
'platform_channel_id' => $platformChannelId,
|
'platform_channel_id' => $platformChannelId,
|
||||||
|
|
@ -23,11 +22,5 @@ public function execute(int $feedId, int $platformChannelId, int $priority = 0,
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($route->wasRecentlyCreated && $route->is_active) {
|
|
||||||
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $route;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,17 @@ public function execute(Article $article, string $content): void
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($activeRoutes as $route) {
|
// Batch-load all active keywords for this feed, grouped by channel
|
||||||
$this->createForRoute($article, $route, $content);
|
$keywordsByChannel = Keyword::where('feed_id', $article->feed_id)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
->where('is_active', true)
|
||||||
->get();
|
->get()
|
||||||
|
->groupBy('platform_channel_id');
|
||||||
|
|
||||||
// Match keywords against full article content, title, and description
|
// Match keywords against full article content, title, and description
|
||||||
$searchableContent = $content.' '.$article->title.' '.$article->description;
|
$searchableContent = $content.' '.$article->title.' '.$article->description;
|
||||||
|
|
||||||
|
foreach ($activeRoutes as $route) {
|
||||||
|
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect());
|
||||||
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
|
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
|
||||||
|
|
||||||
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
|
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
|
||||||
|
|
@ -51,6 +48,7 @@ public function createForRoute(Article $article, Route $route, string $content):
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Keyword> $keywords
|
* @param Collection<int, Keyword> $keywords
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Events\RouteActivated;
|
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\Keyword;
|
use App\Models\Keyword;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
|
|
@ -73,8 +72,6 @@ public function createRoute(): void
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
RouteActivated::dispatch($this->newFeedId, $this->newChannelId);
|
|
||||||
|
|
||||||
$this->closeCreateModal();
|
$this->closeCreateModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,10 +129,6 @@ public function toggle(int $feedId, int $channelId): void
|
||||||
|
|
||||||
$route->is_active = ! $route->is_active;
|
$route->is_active = ! $route->is_active;
|
||||||
$route->save();
|
$route->save();
|
||||||
|
|
||||||
if ($route->is_active) {
|
|
||||||
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $feedId, int $channelId): void
|
public function delete(int $feedId, int $channelId): void
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
* @property string $url
|
* @property string $url
|
||||||
* @property string $title
|
* @property string $title
|
||||||
* @property string|null $description
|
* @property string|null $description
|
||||||
* @property string|null $content
|
|
||||||
* @property Carbon|null $validated_at
|
* @property Carbon|null $validated_at
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ext-gd": "*",
|
|
||||||
"blade-ui-kit/blade-heroicons": "^2.6",
|
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/horizon": "^5.29",
|
"laravel/horizon": "^5.29",
|
||||||
|
|
|
||||||
5
composer.lock
generated
5
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "67923d1e9e79798f3ebe2e79954f4795",
|
"content-hash": "e6ce5effb7f8c4d5a3f6d8cd04b6e299",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "blade-ui-kit/blade-heroicons",
|
"name": "blade-ui-kit/blade-heroicons",
|
||||||
|
|
@ -8747,8 +8747,7 @@
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2",
|
"php": "^8.2"
|
||||||
"ext-gd": "*"
|
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@
|
||||||
'type' => 'website',
|
'type' => 'website',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'languages' => [
|
'languages' => [
|
||||||
// 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=1&count=50&search=&start=&end=&newsroomId=70&language=EN'],
|
||||||
'en' => ['url' => 'https://capi.belga.press/belgapress/api/public/pressreleases?offset=0&count=50&search=&start=&end=&newsroomId=70&language=EN'],
|
|
||||||
],
|
],
|
||||||
'parsers' => [
|
'parsers' => [
|
||||||
'homepage' => BelgaHomepageParserAdapter::class,
|
'homepage' => BelgaHomepageParserAdapter::class,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->words(3, true),
|
'name' => $this->faker->words(3, true),
|
||||||
'url' => $this->faker->unique()->url(),
|
'url' => $this->faker->url(),
|
||||||
'type' => $this->faker->randomElement(['website', 'rss']),
|
'type' => $this->faker->randomElement(['website', 'rss']),
|
||||||
'provider' => $this->faker->randomElement(['vrt', 'belga']),
|
'provider' => $this->faker->randomElement(['vrt', 'belga']),
|
||||||
'language_id' => null,
|
'language_id' => null,
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ class LanguageFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
// Not a real language code: tests hardcode 'en', 'fr', 'nl' and
|
'short_code' => $this->faker->unique()->languageCode(),
|
||||||
// others, and faker's pool would collide with them.
|
|
||||||
'short_code' => 'x-'.$this->faker->unique()->numerify('####'),
|
|
||||||
'name' => $this->faker->unique()->word(),
|
'name' => $this->faker->unique()->word(),
|
||||||
'native_name' => $this->faker->optional()->word(),
|
'native_name' => $this->faker->optional()->word(),
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ public function definition(): array
|
||||||
return [
|
return [
|
||||||
'platform' => PlatformEnum::LEMMY,
|
'platform' => PlatformEnum::LEMMY,
|
||||||
'instance_url' => 'https://lemmy.'.$this->faker->domainName(),
|
'instance_url' => 'https://lemmy.'.$this->faker->domainName(),
|
||||||
'username' => $this->faker->unique()->userName(),
|
'username' => $this->faker->userName(),
|
||||||
'password' => 'test-password',
|
'password' => 'test-password',
|
||||||
'settings' => [],
|
'settings' => [],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public function definition(): array
|
||||||
return [
|
return [
|
||||||
'platform' => 'lemmy',
|
'platform' => 'lemmy',
|
||||||
'name' => $this->faker->words(2, true),
|
'name' => $this->faker->words(2, true),
|
||||||
'url' => $this->faker->unique()->url(),
|
'url' => $this->faker->url(),
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ public function lemmy(): static
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'platform' => 'lemmy',
|
'platform' => 'lemmy',
|
||||||
'name' => 'Lemmy '.$this->faker->word(),
|
'name' => 'Lemmy '.$this->faker->word(),
|
||||||
'url' => 'https://lemmy.'.$this->faker->unique()->domainName(),
|
'url' => 'https://lemmy.'.$this->faker->domainName(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* channel_id held a community slug, resolved to Lemmy's numeric id on every
|
* channel_id held a community slug, resolved to Lemmy's numeric id on every
|
||||||
* publish and every sync. It now holds that id directly; `name` remains the slug.
|
* publish and every sync. It now holds that id directly; `name` remains the slug.
|
||||||
*
|
|
||||||
* Slugs cannot be converted without asking the instance for the id, and a
|
|
||||||
* migration must not depend on a remote service: an earlier version of this file
|
|
||||||
* did, and a rate-limited login left this and eight later migrations unapplied
|
|
||||||
* across several releases. Channels still holding a slug are deleted instead, to
|
|
||||||
* be recreated through the UI, which validates the community on the instance.
|
|
||||||
*/
|
*/
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
$this->deleteChannelsWithUnconvertibleIds();
|
// Every id is resolved before any DDL runs: these lookups hit the live
|
||||||
|
// instance and MariaDB will not roll back a schema change if one fails.
|
||||||
|
$resolved = DB::table('platform_channels')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn (object $channel) => [$channel->id => $this->resolve($channel)]);
|
||||||
|
|
||||||
Schema::table('platform_channels', function (Blueprint $table) {
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
$table->dropUnique('platform_channels_channel_id_unique');
|
$table->dropUnique('platform_channels_channel_id_unique');
|
||||||
|
|
@ -30,9 +30,11 @@ public function up(): void
|
||||||
$table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id');
|
$table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
DB::table('platform_channels')->update([
|
foreach ($resolved as $id => $communityId) {
|
||||||
'remote_community_id' => DB::raw('CAST(channel_id AS UNSIGNED)'),
|
DB::table('platform_channels')
|
||||||
]);
|
->where('id', $id)
|
||||||
|
->update(['remote_community_id' => $communityId]);
|
||||||
|
}
|
||||||
|
|
||||||
Schema::table('platform_channels', function (Blueprint $table) {
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
$table->dropColumn('channel_id');
|
$table->dropColumn('channel_id');
|
||||||
|
|
@ -65,28 +67,33 @@ public function down(): void
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function resolve(object $channel): int
|
||||||
* Routes, keywords, route articles and channel posts cascade from the
|
|
||||||
* database. article_publications does not have its foreign key until
|
|
||||||
* 000023, which deletes whatever this leaves orphaned.
|
|
||||||
*/
|
|
||||||
private function deleteChannelsWithUnconvertibleIds(): void
|
|
||||||
{
|
{
|
||||||
$doomed = DB::table('platform_channels')
|
if (is_numeric($channel->channel_id)) {
|
||||||
->get(['id', 'name', 'channel_id'])
|
return (int) $channel->channel_id;
|
||||||
->reject(fn (object $channel) => ctype_digit((string) $channel->channel_id))
|
|
||||||
->pluck('name', 'id');
|
|
||||||
|
|
||||||
if ($doomed->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::warning(sprintf(
|
$instance = DB::table('platform_instances')->find($channel->platform_instance_id);
|
||||||
'Deleting %d channel(s) whose community id is a slug rather than a numeric id: %s. Recreate them from the Channels page.',
|
|
||||||
$doomed->count(),
|
|
||||||
$doomed->implode(', '),
|
|
||||||
));
|
|
||||||
|
|
||||||
DB::table('platform_channels')->whereIn('id', $doomed->keys())->delete();
|
if (! $instance) {
|
||||||
|
throw new RuntimeException("Channel {$channel->id} has no platform instance; cannot resolve its community id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$account = PlatformAccount::where('instance_url', $instance->url)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $account) {
|
||||||
|
throw new RuntimeException("No active account for {$instance->url}; cannot resolve community '{$channel->channel_id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$api = new LemmyApiService($instance->url);
|
||||||
|
$token = $api->login($account->username, $account->password);
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
throw new RuntimeException("Could not authenticate against {$instance->url} to resolve community '{$channel->channel_id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $api->getCommunityId($channel->channel_id, $token);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?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.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
# Image for CI: PHP and Composer only, no runtime server or frontend toolchain.
|
|
||||||
# Tests run against sqlite in memory (see .env.testing), so no database client
|
|
||||||
# or cache extension is needed.
|
|
||||||
#
|
|
||||||
# Published as fedi-feed-router-ci:php<version>-<revision>, not :latest. Runners
|
|
||||||
# cache mutable tags and will not re-pull them, so bump the revision in the tag
|
|
||||||
# and in ci.yml whenever this file changes.
|
|
||||||
#
|
|
||||||
# Debian-based rather than alpine: the alpine build hit repeated DNS resolution
|
|
||||||
# timeouts against codeload.github.com during composer install.
|
|
||||||
#
|
|
||||||
# gd is not declared in composer.lock but ThumbnailUploader calls it directly.
|
|
||||||
FROM php:8.3-cli
|
|
||||||
|
|
||||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
|
||||||
|
|
||||||
RUN install-php-extensions \
|
|
||||||
pdo_sqlite \
|
|
||||||
mbstring \
|
|
||||||
dom \
|
|
||||||
xml \
|
|
||||||
fileinfo \
|
|
||||||
pcntl \
|
|
||||||
gd
|
|
||||||
|
|
||||||
# nodejs is not used by the app's tests; the Forgejo/GitHub JavaScript actions
|
|
||||||
# (checkout, cache) are executed with it inside this container.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git unzip nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
@ -91,10 +91,6 @@ pkgs.mkShell {
|
||||||
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
|
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
|
# BUILD COMMANDS
|
||||||
# ===================
|
# ===================
|
||||||
|
|
@ -143,7 +139,6 @@ pkgs.mkShell {
|
||||||
echo " dev-logs-db Tail database logs"
|
echo " dev-logs-db Tail database logs"
|
||||||
echo " dev-shell Shell into app container"
|
echo " dev-shell Shell into app container"
|
||||||
echo " dev-artisan <cmd> Run artisan command"
|
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 " base-build Build and push base image"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Services:"
|
echo "Services:"
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use App\Events\ActivityLogged;
|
use App\Events\ActivityLogged;
|
||||||
use App\Events\ExceptionOccurred;
|
use App\Events\ExceptionOccurred;
|
||||||
use App\Events\NewArticleFetched;
|
use App\Events\NewArticleFetched;
|
||||||
use App\Events\RouteActivated;
|
|
||||||
use App\Events\RouteArticleApproved;
|
use App\Events\RouteArticleApproved;
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
@ -26,7 +25,6 @@ public static function eventProvider(): array
|
||||||
'ActivityLogged' => [ActivityLogged::class],
|
'ActivityLogged' => [ActivityLogged::class],
|
||||||
'ExceptionOccurred' => [ExceptionOccurred::class],
|
'ExceptionOccurred' => [ExceptionOccurred::class],
|
||||||
'NewArticleFetched' => [NewArticleFetched::class],
|
'NewArticleFetched' => [NewArticleFetched::class],
|
||||||
'RouteActivated' => [RouteActivated::class],
|
|
||||||
'RouteArticleApproved' => [RouteArticleApproved::class],
|
'RouteArticleApproved' => [RouteArticleApproved::class],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -36,31 +37,39 @@ private function restoreSlugColumn(): void
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedChannel(PlatformInstance $instance, string $channelId, string $name): int
|
private function seedChannel(PlatformInstance $instance, string $slug): int
|
||||||
{
|
{
|
||||||
return DB::table('platform_channels')->insertGetId([
|
return DB::table('platform_channels')->insertGetId([
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => $name,
|
'name' => $slug,
|
||||||
'display_name' => ucfirst($name),
|
'display_name' => ucfirst($slug),
|
||||||
'channel_id' => $channelId,
|
'channel_id' => $slug,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function prepare(): PlatformInstance
|
private function instanceWithAccount(): PlatformInstance
|
||||||
|
{
|
||||||
|
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||||||
|
PlatformAccount::factory()->create(['instance_url' => 'https://lemmy.test', 'is_active' => true]);
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_replaces_the_slug_with_the_resolved_community_id(): void
|
||||||
{
|
{
|
||||||
$this->restoreSlugColumn();
|
$this->restoreSlugColumn();
|
||||||
DB::table('platform_channels')->delete();
|
DB::table('platform_channels')->delete();
|
||||||
|
|
||||||
return PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
$instance = $this->instanceWithAccount();
|
||||||
}
|
$id = $this->seedChannel($instance, 'news');
|
||||||
|
|
||||||
public function test_it_keeps_a_channel_whose_id_is_already_numeric(): void
|
Http::fake([
|
||||||
{
|
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
|
||||||
$instance = $this->prepare();
|
'*/api/v3/community*' => Http::response(['community_view' => ['community' => ['id' => 8]]]),
|
||||||
$id = $this->seedChannel($instance, '8', 'news');
|
]);
|
||||||
|
|
||||||
$this->runMigration();
|
$this->runMigration();
|
||||||
|
|
||||||
|
|
@ -68,38 +77,34 @@ public function test_it_keeps_a_channel_whose_id_is_already_numeric(): void
|
||||||
$this->assertSame('news', DB::table('platform_channels')->where('id', $id)->value('name'));
|
$this->assertSame('news', DB::table('platform_channels')->where('id', $id)->value('name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_deletes_a_channel_whose_id_is_still_a_slug(): void
|
public function test_it_aborts_when_a_community_cannot_be_resolved(): void
|
||||||
{
|
{
|
||||||
$instance = $this->prepare();
|
$this->restoreSlugColumn();
|
||||||
$id = $this->seedChannel($instance, 'news', 'news');
|
DB::table('platform_channels')->delete();
|
||||||
|
|
||||||
|
$instance = $this->instanceWithAccount();
|
||||||
|
$this->seedChannel($instance, 'gone');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
|
||||||
|
'*/api/v3/community*' => Http::response('not found', 404),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
|
||||||
$this->runMigration();
|
$this->runMigration();
|
||||||
|
|
||||||
$this->assertDatabaseMissing('platform_channels', ['id' => $id]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_keeps_numeric_channels_while_deleting_slug_ones(): void
|
public function test_it_aborts_when_the_instance_has_no_active_account(): void
|
||||||
{
|
{
|
||||||
$instance = $this->prepare();
|
$this->restoreSlugColumn();
|
||||||
$kept = $this->seedChannel($instance, '8', 'news');
|
DB::table('platform_channels')->delete();
|
||||||
$deleted = $this->seedChannel($instance, 'nieuws', 'nieuws');
|
|
||||||
|
$instance = PlatformInstance::factory()->create(['url' => 'https://no-account.test']);
|
||||||
|
$this->seedChannel($instance, 'news');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
$this->runMigration();
|
$this->runMigration();
|
||||||
|
|
||||||
$this->assertDatabaseHas('platform_channels', ['id' => $kept]);
|
|
||||||
$this->assertDatabaseMissing('platform_channels', ['id' => $deleted]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_makes_no_http_requests(): void
|
|
||||||
{
|
|
||||||
Http::preventStrayRequests();
|
|
||||||
|
|
||||||
$instance = $this->prepare();
|
|
||||||
$this->seedChannel($instance, 'news', 'news');
|
|
||||||
$this->seedChannel($instance, '8', 'other');
|
|
||||||
|
|
||||||
$this->runMigration();
|
|
||||||
|
|
||||||
Http::assertNothingSent();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -36,11 +35,6 @@ class PublishNextArticleJobTest extends TestCase
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
// The daily cap counts from startOfDay, so tests placing publications a
|
|
||||||
// couple of hours back land on the previous day when run after midnight.
|
|
||||||
Carbon::setTestNow('2026-07-15 12:00:00');
|
|
||||||
|
|
||||||
$this->notificationService = new NotificationService;
|
$this->notificationService = new NotificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,54 +319,6 @@ public function test_daily_cap_counts_each_channel_publication_separately(): voi
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_daily_cap_counts_from_midnight_not_a_rolling_window(): void
|
|
||||||
{
|
|
||||||
Carbon::setTestNow('2026-07-15 01:00:00');
|
|
||||||
|
|
||||||
$this->createApprovedRouteArticle();
|
|
||||||
|
|
||||||
ArticlePublication::factory()->count(3)->create(['published_at' => Carbon::parse('2026-07-14 23:00:00')]);
|
|
||||||
Setting::setArticlePublishingInterval(0);
|
|
||||||
Setting::setDailyPublishCap(3);
|
|
||||||
|
|
||||||
$articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
|
|
||||||
$articleFetcherMock->shouldReceive('execute')
|
|
||||||
->once()
|
|
||||||
->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
|
|
||||||
|
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
|
||||||
->once()
|
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
|
||||||
|
|
||||||
$this->assertTrue(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_daily_cap_counts_a_publication_just_after_midnight(): void
|
|
||||||
{
|
|
||||||
Carbon::setTestNow('2026-07-15 01:00:00');
|
|
||||||
|
|
||||||
$this->createApprovedRouteArticle();
|
|
||||||
|
|
||||||
ArticlePublication::factory()->count(3)->create(['published_at' => Carbon::parse('2026-07-15 00:30:00')]);
|
|
||||||
Setting::setArticlePublishingInterval(0);
|
|
||||||
Setting::setDailyPublishCap(3);
|
|
||||||
|
|
||||||
$articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
|
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
|
||||||
|
|
||||||
$articleFetcherMock->shouldNotReceive('execute');
|
|
||||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
|
||||||
|
|
||||||
$this->assertTrue(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_handle_ignores_publications_from_previous_days(): void
|
public function test_handle_ignores_publications_from_previous_days(): void
|
||||||
{
|
{
|
||||||
$this->createApprovedRouteArticle();
|
$this->createApprovedRouteArticle();
|
||||||
|
|
@ -626,7 +572,6 @@ public function test_job_can_be_serialized(): void
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
Carbon::setTestNow();
|
|
||||||
Mockery::close();
|
Mockery::close();
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,7 @@ public function test_adapter_api_url_pins_load_bearing_query_params(): void
|
||||||
|
|
||||||
$this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $url);
|
$this->assertStringStartsWith('https://capi.belga.press/belgapress/api/public/pressreleases?', $url);
|
||||||
$this->assertStringContainsString('newsroomId=70', $url);
|
$this->assertStringContainsString('newsroomId=70', $url);
|
||||||
// 0-based item index: offset=1 skipped the newest release entirely (#158).
|
$this->assertStringContainsString('offset=1', $url);
|
||||||
$this->assertStringContainsString('offset=0&', $url);
|
|
||||||
$this->assertStringContainsString('count=50', $url);
|
$this->assertStringContainsString('count=50', $url);
|
||||||
$this->assertStringContainsString('language=EN', $url);
|
$this->assertStringContainsString('language=EN', $url);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue