Merge pull request 'release/v1.4.1' (#154) from release/v1.4.1 into main
All checks were successful
Build and Push Base Images / images (docker/build/Dockerfile.base, fedi-feed-router-base, php8.3-1) (push) Successful in 6m33s
Build and Push Base Images / images (docker/build/Dockerfile.ci, fedi-feed-router-ci, php8.3-3) (push) Successful in 8m1s
Build and Push Docker Image / build (push) Successful in 5m53s
All checks were successful
Build and Push Base Images / images (docker/build/Dockerfile.base, fedi-feed-router-base, php8.3-1) (push) Successful in 6m33s
Build and Push Base Images / images (docker/build/Dockerfile.ci, fedi-feed-router-ci, php8.3-3) (push) Successful in 8m1s
Build and Push Docker Image / build (push) Successful in 5m53s
Reviewed-on: #154
This commit is contained in:
commit
c460db2e5b
12 changed files with 215 additions and 96 deletions
|
|
@ -4,27 +4,20 @@ on:
|
|||
push:
|
||||
branches: ['release/*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, 'release/*']
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
image: forge.lvl0.xyz/lvl0/fedi-feed-router-ci:php8.3-3
|
||||
steps:
|
||||
- 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
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
path: ~/.cache/composer
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: composer-
|
||||
|
||||
|
|
@ -41,4 +34,4 @@ jobs:
|
|||
run: vendor/bin/phpstan analyse --memory-limit=1G
|
||||
|
||||
- name: Tests
|
||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
|
|
|||
47
.forgejo/workflows/images.yml
Normal file
47
.forgejo/workflows/images.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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 }}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"license": "AGPL-3.0-only",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-gd": "*",
|
||||
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||
"laravel/framework": "^12.0",
|
||||
"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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e6ce5effb7f8c4d5a3f6d8cd04b6e299",
|
||||
"content-hash": "67923d1e9e79798f3ebe2e79954f4795",
|
||||
"packages": [
|
||||
{
|
||||
"name": "blade-ui-kit/blade-heroicons",
|
||||
|
|
@ -8747,7 +8747,8 @@
|
|||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
"php": "^8.2",
|
||||
"ext-gd": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ public function definition(): array
|
|||
{
|
||||
return [
|
||||
'name' => $this->faker->words(3, true),
|
||||
'url' => $this->faker->url(),
|
||||
'url' => $this->faker->unique()->url(),
|
||||
'type' => $this->faker->randomElement(['website', 'rss']),
|
||||
'provider' => $this->faker->randomElement(['vrt', 'belga']),
|
||||
'language_id' => null,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ class LanguageFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'short_code' => $this->faker->unique()->languageCode(),
|
||||
// Not a real language code: tests hardcode 'en', 'fr', 'nl' and
|
||||
// others, and faker's pool would collide with them.
|
||||
'short_code' => 'x-'.$this->faker->unique()->numerify('####'),
|
||||
'name' => $this->faker->unique()->word(),
|
||||
'native_name' => $this->faker->optional()->word(),
|
||||
'is_active' => true,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public function definition(): array
|
|||
return [
|
||||
'platform' => PlatformEnum::LEMMY,
|
||||
'instance_url' => 'https://lemmy.'.$this->faker->domainName(),
|
||||
'username' => $this->faker->userName(),
|
||||
'username' => $this->faker->unique()->userName(),
|
||||
'password' => 'test-password',
|
||||
'settings' => [],
|
||||
'is_active' => true,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ public function definition(): array
|
|||
return [
|
||||
'platform' => 'lemmy',
|
||||
'name' => $this->faker->words(2, true),
|
||||
'url' => $this->faker->url(),
|
||||
'url' => $this->faker->unique()->url(),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ public function lemmy(): static
|
|||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => 'lemmy',
|
||||
'name' => 'Lemmy '.$this->faker->word(),
|
||||
'url' => 'https://lemmy.'.$this->faker->domainName(),
|
||||
'url' => 'https://lemmy.'.$this->faker->unique()->domainName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 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)]);
|
||||
$this->deleteChannelsWithUnconvertibleIds();
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropUnique('platform_channels_channel_id_unique');
|
||||
|
|
@ -30,11 +30,9 @@ public function up(): void
|
|||
$table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id');
|
||||
});
|
||||
|
||||
foreach ($resolved as $id => $communityId) {
|
||||
DB::table('platform_channels')
|
||||
->where('id', $id)
|
||||
->update(['remote_community_id' => $communityId]);
|
||||
}
|
||||
DB::table('platform_channels')->update([
|
||||
'remote_community_id' => DB::raw('CAST(channel_id AS UNSIGNED)'),
|
||||
]);
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropColumn('channel_id');
|
||||
|
|
@ -67,33 +65,28 @@ 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
|
||||
{
|
||||
if (is_numeric($channel->channel_id)) {
|
||||
return (int) $channel->channel_id;
|
||||
$doomed = DB::table('platform_channels')
|
||||
->get(['id', 'name', 'channel_id'])
|
||||
->reject(fn (object $channel) => ctype_digit((string) $channel->channel_id))
|
||||
->pluck('name', 'id');
|
||||
|
||||
if ($doomed->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$instance = DB::table('platform_instances')->find($channel->platform_instance_id);
|
||||
Log::warning(sprintf(
|
||||
'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(', '),
|
||||
));
|
||||
|
||||
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);
|
||||
DB::table('platform_channels')->whereIn('id', $doomed->keys())->delete();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
docker/build/Dockerfile.ci
Normal file
32
docker/build/Dockerfile.ci
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -37,39 +36,31 @@ private function restoreSlugColumn(): void
|
|||
});
|
||||
}
|
||||
|
||||
private function seedChannel(PlatformInstance $instance, string $slug): int
|
||||
private function seedChannel(PlatformInstance $instance, string $channelId, string $name): int
|
||||
{
|
||||
return DB::table('platform_channels')->insertGetId([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => $slug,
|
||||
'display_name' => ucfirst($slug),
|
||||
'channel_id' => $slug,
|
||||
'name' => $name,
|
||||
'display_name' => ucfirst($name),
|
||||
'channel_id' => $channelId,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
private function prepare(): PlatformInstance
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
DB::table('platform_channels')->delete();
|
||||
|
||||
$instance = $this->instanceWithAccount();
|
||||
$id = $this->seedChannel($instance, 'news');
|
||||
return PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||||
}
|
||||
|
||||
Http::fake([
|
||||
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
|
||||
'*/api/v3/community*' => Http::response(['community_view' => ['community' => ['id' => 8]]]),
|
||||
]);
|
||||
public function test_it_keeps_a_channel_whose_id_is_already_numeric(): void
|
||||
{
|
||||
$instance = $this->prepare();
|
||||
$id = $this->seedChannel($instance, '8', 'news');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
|
|
@ -77,34 +68,38 @@ public function test_it_replaces_the_slug_with_the_resolved_community_id(): void
|
|||
$this->assertSame('news', DB::table('platform_channels')->where('id', $id)->value('name'));
|
||||
}
|
||||
|
||||
public function test_it_aborts_when_a_community_cannot_be_resolved(): void
|
||||
public function test_it_deletes_a_channel_whose_id_is_still_a_slug(): void
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
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);
|
||||
$instance = $this->prepare();
|
||||
$id = $this->seedChannel($instance, 'news', 'news');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertDatabaseMissing('platform_channels', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function test_it_aborts_when_the_instance_has_no_active_account(): void
|
||||
public function test_it_keeps_numeric_channels_while_deleting_slug_ones(): void
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
DB::table('platform_channels')->delete();
|
||||
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://no-account.test']);
|
||||
$this->seedChannel($instance, 'news');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$instance = $this->prepare();
|
||||
$kept = $this->seedChannel($instance, '8', 'news');
|
||||
$deleted = $this->seedChannel($instance, 'nieuws', 'nieuws');
|
||||
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -35,6 +36,11 @@ class PublishNextArticleJobTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +325,54 @@ public function test_daily_cap_counts_each_channel_publication_separately(): voi
|
|||
$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
|
||||
{
|
||||
$this->createApprovedRouteArticle();
|
||||
|
|
@ -572,6 +626,7 @@ public function test_job_can_be_serialized(): void
|
|||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue