Compare commits

...

6 commits

37 changed files with 675 additions and 400 deletions

View file

@ -23,14 +23,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=ffr_dev
DB_USERNAME=ffr
DB_PASSWORD=ffr
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -38,15 +38,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=redis
CACHE_STORE=database
CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

View file

@ -0,0 +1,41 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
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: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:${TAG},forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
/public/build
/public/hot
/public/storage
/public/vendor
/storage/*.key
/storage/pail
/vendor

View file

@ -13,14 +13,14 @@ ## Features
## Self-hosting
The production image is available at `codeberg.org/lvl0/ffr:latest`.
The production image is available at `forge.lvl0.xyz/lvl0/fedi-feed-router:latest`.
### docker-compose.yml
```yaml
services:
app:
image: codeberg.org/lvl0/ffr:latest
image: forge.lvl0.xyz/lvl0/fedi-feed-router:latest
container_name: ffr_app
restart: always
ports:
@ -86,7 +86,7 @@ ## Development
### NixOS / Nix
```bash
git clone https://codeberg.org/lvl0/ffr.git
git clone https://forge.lvl0.xyz/lvl0/fedi-feed-router.git
cd ffr
nix-shell
```
@ -104,7 +104,6 @@ #### Available Commands
| `dev-logs-db` | Follow database logs |
| `dev-shell` | Enter app container |
| `dev-artisan <cmd>` | Run artisan commands |
| `prod-build [tag]` | Build and push prod image (default: latest) |
#### Services
@ -125,4 +124,4 @@ ## License
## Support
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues).
For issues and questions, please use [Issues](https://forge.lvl0.xyz/lvl0/fedi-feed-router/issues).

View file

@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse
try {
$validated = $request->validate([
'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean',
'publishing_approvals_enabled' => 'boolean',
]);
if (isset($validated['article_processing_enabled'])) {
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
}
if (isset($validated['enable_publishing_approvals'])) {
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']);
if (isset($validated['publishing_approvals_enabled'])) {
Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
}
$updatedSettings = [
@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
}
}
}
}

View file

@ -21,6 +21,7 @@ public function toArray(Request $request): array
'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(),

View file

@ -0,0 +1,64 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticleListener implements ShouldQueue
{
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
) {}
public function handle(ArticleApproved $event): void
{
$article = $event->article->fresh();
// Skip if already published
if ($article->articlePublication()->exists()) {
return;
}
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
}
} catch (Exception $e) {
$article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -3,16 +3,20 @@
namespace App\Listeners;
use App\Events\NewArticleFetched;
use App\Events\ArticleApproved;
use App\Models\Setting;
use App\Services\Article\ValidationService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class ValidateArticleListener implements ShouldQueue
{
public string $queue = 'default';
public function handle(NewArticleFetched $event, ValidationService $validationService): void
public function __construct(
private ValidationService $validationService
) {}
public function handle(NewArticleFetched $event): void
{
$article = $event->article;
@ -20,12 +24,25 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
return;
}
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) {
return;
}
$article = $validationService->validate($article);
try {
$article = $this->validationService->validate($article);
} catch (Exception $e) {
logger()->error('Article validation failed', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
return;
}
if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection)
@ -33,16 +50,10 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
return;
}
// Check if approval system is enabled
if (Setting::isPublishingApprovalsEnabled()) {
// If approvals are enabled, only proceed if article is approved
if ($article->isApproved()) {
event(new ArticleApproved($article));
}
// If not approved, article will wait for manual approval
} else {
// If approvals are disabled, proceed with publishing
event(new ArticleApproved($article));
// If approvals are enabled, article waits for manual approval.
// If approvals are disabled, auto-approve and publish.
if (! Setting::isPublishingApprovalsEnabled()) {
$article->approve();
}
}
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
@ -17,7 +18,7 @@
class Onboarding extends Component
{
// Step tracking (1-6: welcome, platform, feed, channel, route, complete)
// Step tracking (1-6: welcome, platform, channel, feed, route, complete)
public int $step = 1;
// Platform form
@ -142,7 +143,14 @@ public function createPlatformAccount(): void
$fullInstanceUrl = 'https://' . $this->instanceUrl;
try {
// Create or get platform instance
// Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => 'lemmy',
@ -151,13 +159,6 @@ public function createPlatformAccount(): void
'is_active' => true,
]);
// Authenticate with Lemmy API
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Create platform account
$platformAccount = PlatformAccount::create([
'platform' => 'lemmy',
@ -286,6 +287,9 @@ public function createChannel(): void
'updated_at' => now(),
]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$this->nextStep();
} catch (\Exception $e) {
$this->formErrors['general'] = 'Failed to create channel. Please try again.';

View file

@ -40,6 +40,8 @@ class Article extends Model
'published_at',
'author',
'approval_status',
'validated_at',
'publish_status',
];
/**
@ -49,7 +51,9 @@ public function casts(): array
{
return [
'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
@ -57,9 +61,8 @@ public function casts(): array
public function isValid(): bool
{
// In the consolidated schema, we only have approval_status
// Consider 'approved' status as valid
return $this->approval_status === 'approved';
// Article is valid if it passed validation and wasn't rejected
return $this->validated_at !== null && ! $this->isRejected();
}
public function isApproved(): bool
@ -130,10 +133,8 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class);
}
protected static function booted(): void
public function dispatchFetchedEvent(): void
{
static::created(function ($article) {
event(new NewArticleFetched($article));
});
event(new NewArticleFetched($this));
}
}

View file

@ -42,6 +42,25 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
->exists();
}
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
{
if (!$url && !$title) {
return false;
}
return self::where('platform', $platform)
->where('channel_id', $channelId)
->where(function ($query) use ($url, $title) {
if ($url) {
$query->orWhere('url', $url);
}
if ($title) {
$query->orWhere('title', $title);
}
})
->exists();
}
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
{
return self::updateOrCreate(

View file

@ -52,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void
public static function isPublishingApprovalsEnabled(): bool
{
return static::getBool('enable_publishing_approvals', false);
return static::getBool('enable_publishing_approvals', true);
}
public static function setPublishingApprovalsEnabled(bool $enabled): void

View file

@ -73,10 +73,12 @@ public function login(string $username, string $password): ?string
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
continue;
}
return null;
// Connection failed - throw exception to distinguish from auth failure
throw new Exception('Connection failed: ' . $e->getMessage());
}
}
// If we get here without a token, it's an auth failure (not connection)
return null;
}

View file

@ -28,13 +28,30 @@ public function __construct(PlatformAccount $account)
*/
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
{
$token = resolve(LemmyAuthService::class)->getToken($this->account);
$authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account);
// Use the language ID from extracted data (should be set during validation)
try {
return $this->createPost($token, $extractedData, $channel, $article);
} catch (Exception $e) {
// If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article);
}
throw $e;
}
}
/**
* @param array<string, mixed> $extractedData
* @return array<string, mixed>
*/
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
{
$languageId = $extractedData['language_id'] ?? null;
// Resolve community name to numeric ID if needed
$communityId = is_numeric($channel->channel_id)
$communityId = is_numeric($channel->channel_id)
? (int) $channel->channel_id
: $this->api->getCommunityId($channel->channel_id, $token);

View file

@ -30,6 +30,10 @@ public function boot(): void
\App\Listeners\ValidateArticleListener::class,
);
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class)
->reportable(function (Throwable $e) {

View file

@ -103,27 +103,27 @@ public function fetchArticleData(Article $article): array
private function saveArticle(string $url, ?int $feedId = null): Article
{
$existingArticle = Article::where('url', $url)->first();
if ($existingArticle) {
return $existingArticle;
}
// Extract a basic title from URL as fallback
$fallbackTitle = $this->generateFallbackTitle($url);
try {
return Article::create([
'url' => $url,
'feed_id' => $feedId,
'title' => $fallbackTitle,
]);
$article = Article::firstOrCreate(
['url' => $url],
[
'feed_id' => $feedId,
'title' => $fallbackTitle,
]
);
if ($article->wasRecentlyCreated) {
$article->dispatchFetchedEvent();
}
return $article;
} catch (\Exception $e) {
$this->logSaver->error("Failed to create article - title validation failed", null, [
$this->logSaver->error("Failed to create article", null, [
'url' => $url,
'feed_id' => $feedId,
'error' => $e->getMessage(),
'suggestion' => 'Check regex parsing patterns for title extraction'
]);
throw $e;
}
@ -134,12 +134,12 @@ private function generateFallbackTitle(string $url): string
// Extract filename from URL as a basic fallback title
$path = parse_url($url, PHP_URL_PATH);
$filename = basename($path ?: $url);
// Remove file extension and convert to readable format
$title = preg_replace('/\.[^.]*$/', '', $filename);
$title = str_replace(['-', '_'], ' ', $title);
$title = ucwords($title);
return $title ?: 'Untitled Article';
}
}

View file

@ -15,7 +15,7 @@ public function validate(Article $article): Article
logger('Checking keywords for article: ' . $article->id);
$articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description)
$updateData = [];
@ -24,23 +24,29 @@ public function validate(Article $article): Article
$updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null;
}
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [
'article_id' => $article->id,
'url' => $article->url
]);
$updateData['approval_status'] = 'rejected';
$article->update($updateData);
return $article->refresh();
}
// Validate using extracted content (not stored)
$validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData);
return $article->refresh();
@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool
// Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'

View file

@ -14,6 +14,22 @@ class LemmyAuthService
* @throws PlatformAuthException
*/
public function getToken(PlatformAccount $account): string
{
// Use cached token if available
$cachedToken = $account->settings['api_token'] ?? null;
if ($cachedToken) {
return $cachedToken;
}
return $this->refreshToken($account);
}
/**
* Clear cached token and re-authenticate.
*
* @throws PlatformAuthException
*/
public function refreshToken(PlatformAccount $account): string
{
if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
@ -26,6 +42,11 @@ public function getToken(PlatformAccount $account): string
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
}
// Cache the token for future use
$settings = $account->settings ?? [];
$settings['api_token'] = $token;
$account->update(['settings' => $settings]);
return $token;
}
@ -65,6 +86,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
if (str_contains($e->getMessage(), 'Rate limited by')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
}
// Check if it's a connection failure
if (str_contains($e->getMessage(), 'Connection failed')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
// For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}

View file

@ -7,6 +7,7 @@
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver;
@ -78,7 +79,7 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
{
// Get active keywords for this route
$activeKeywords = $route->keywords->where('is_active', true);
// If no keywords are defined for this route, the route matches any article
if ($activeKeywords->isEmpty()) {
return true;
@ -113,6 +114,23 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
{
try {
// Check if this URL or title was already posted to this channel
$title = $extractedData['title'] ?? $article->title;
if (PlatformChannelPost::duplicateExists(
$channel->platformInstance->platform,
(string) $channel->channel_id,
$article->url,
$title
)) {
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
'article_id' => $article->id,
'url' => $article->url,
'title' => $title,
]);
return null;
}
$publisher = $this->makePublisher($account);
$postData = $publisher->publishToChannel($article, $extractedData, $channel);

View file

@ -182,7 +182,7 @@
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'publishing', 'feed-discovery'],
'queue' => ['default', 'publishing', 'feed-discovery', 'sync'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,

View file

@ -26,6 +26,7 @@ public function definition(): array
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
];
}
}

View file

@ -20,10 +20,13 @@ public function up(): void
$table->string('author')->nullable();
$table->unsignedBigInteger('feed_id')->nullable();
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending');
$table->timestamp('validated_at')->nullable();
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])->default('unpublished');
$table->timestamps();
$table->index(['published_at', 'approval_status']);
$table->index('feed_id');
$table->unique('url');
});
// Article publications table
@ -71,4 +74,4 @@ public function down(): void
Schema::dropIfExists('logs');
Schema::dropIfExists('settings');
}
};
};

View file

@ -64,20 +64,21 @@ public function up(): void
$table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique');
});
// Platform channel posts table
// Platform channel posts table (synced from platform APIs for duplicate detection)
Schema::create('platform_channel_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('platform_channel_id')->constrained()->onDelete('cascade');
$table->string('platform');
$table->string('channel_id');
$table->string('channel_name')->nullable();
$table->string('post_id');
$table->string('title');
$table->text('content')->nullable();
$table->string('title')->nullable();
$table->string('url')->nullable();
$table->timestamp('posted_at');
$table->string('author');
$table->json('metadata')->nullable();
$table->timestamp('posted_at')->nullable();
$table->timestamps();
$table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique');
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
$table->index(['platform', 'channel_id', 'url']);
$table->index(['platform', 'channel_id', 'title']);
});
// Language platform instance pivot table
@ -102,4 +103,4 @@ public function down(): void
Schema::dropIfExists('platform_accounts');
Schema::dropIfExists('platform_instances');
}
};
};

View file

@ -1,69 +0,0 @@
# ===================
# FFR Production Services
# ===================
services:
app:
build:
context: ../..
dockerfile: Dockerfile
image: codeberg.org/lvl0/ffr:latest
container_name: ffr_app
restart: unless-stopped
ports:
- "8000:8000"
environment:
APP_NAME: "${APP_NAME:-FFR}"
APP_KEY: "${APP_KEY}"
APP_URL: "${APP_URL}"
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: "${DB_DATABASE:-ffr}"
DB_USERNAME: "${DB_USERNAME:-ffr}"
DB_PASSWORD: "${DB_PASSWORD}"
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- ffr-network
db:
image: mariadb:11
container_name: ffr_db
restart: unless-stopped
environment:
MYSQL_DATABASE: "${DB_DATABASE:-ffr}"
MYSQL_USER: "${DB_USERNAME:-ffr}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- ffr-network
redis:
image: redis:7-alpine
container_name: ffr_redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- ffr-network
networks:
ffr-network:
driver: bridge
volumes:
db_data:
redis_data:

View file

@ -1,7 +1 @@
import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
import "./bootstrap";

View file

@ -54,6 +54,20 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
</svg>
Published
</span>
@elseif ($article->publish_status === 'error')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Publish Error
</span>
@elseif ($article->publish_status === 'publishing')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
<svg class="h-3 w-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Publishing...
</span>
@elseif ($article->approval_status === 'approved')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">

View file

@ -16,11 +16,11 @@
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">2</div>
<span>Add your first feed</span>
<span>Configure a channel</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">3</div>
<span>Configure a channel</span>
<span>Add your first feed</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
@ -173,112 +173,8 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
</div>
@endif
{{-- Step 3: Feed --}}
{{-- Step 3: Channel --}}
@if ($step === 3)
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p class="text-gray-600">
Choose from our supported news providers to monitor for new articles
</p>
{{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
<form wire:submit="createFeed" class="space-y-6 mt-8 text-left">
@if (!empty($formErrors['general']))
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-600 text-sm">{{ $formErrors['general'] }}</p>
</div>
@endif
<div>
<label for="feedName" class="block text-sm font-medium text-gray-700 mb-2">
Feed Name
</label>
<input
type="text"
id="feedName"
wire:model="feedName"
placeholder="My News Feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
News Provider
</label>
<select
id="feedProvider"
wire:model="feedProvider"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select news provider</option>
@foreach ($feedProviders as $provider)
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
@endforeach
</select>
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
id="feedLanguageId"
wire:model="feedLanguageId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea
id="feedDescription"
wire:model="feedDescription"
rows="3"
placeholder="Brief description of this feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
@error('feedDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-between">
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</button>
<button
type="submit"
@disabled($isLoading)
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
>
{{ $isLoading ? 'Creating...' : 'Continue' }}
</button>
</div>
</form>
</div>
@endif
{{-- Step 4: Channel --}}
@if ($step === 4)
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Configure Your Channel</h1>
<p class="text-gray-600">
@ -288,8 +184,8 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
{{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
@ -382,6 +278,110 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
</div>
@endif
{{-- Step 4: Feed --}}
@if ($step === 4)
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p class="text-gray-600">
Choose from our supported news providers to monitor for new articles
</p>
{{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
<form wire:submit="createFeed" class="space-y-6 mt-8 text-left">
@if (!empty($formErrors['general']))
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-600 text-sm">{{ $formErrors['general'] }}</p>
</div>
@endif
<div>
<label for="feedName" class="block text-sm font-medium text-gray-700 mb-2">
Feed Name
</label>
<input
type="text"
id="feedName"
wire:model="feedName"
placeholder="My News Feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
News Provider
</label>
<select
id="feedProvider"
wire:model="feedProvider"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select news provider</option>
@foreach ($feedProviders as $provider)
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
@endforeach
</select>
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
id="feedLanguageId"
wire:model="feedLanguageId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea
id="feedDescription"
wire:model="feedDescription"
rows="3"
placeholder="Brief description of this feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
@error('feedDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-between">
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</button>
<button
type="submit"
@disabled($isLoading)
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
>
{{ $isLoading ? 'Creating...' : 'Continue' }}
</button>
</div>
</form>
</div>
@endif
{{-- Step 5: Route --}}
@if ($step === 5)
<div class="text-center mb-8">

View file

@ -38,3 +38,14 @@
});
require __DIR__.'/auth.php';
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
});
Route::fallback(function () {
return response()->json([
'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.',
'api_base' => '/api/v1',
], 404);
});

View file

@ -81,32 +81,6 @@ pkgs.mkShell {
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
}
# ===================
# PROD COMMANDS
# ===================
prod-build() {
local tag="''${1:-latest}"
local image="codeberg.org/lvl0/ffr:$tag"
echo "Building production image: $image"
if ! podman build -t "$image" -f Dockerfile .; then
echo "Build failed!"
return 1
fi
echo ""
echo "Pushing to registry..."
if ! podman push "$image"; then
echo ""
echo "Push failed! You may need to login first:"
echo " podman login codeberg.org"
return 1
fi
echo ""
echo "Done! Image pushed: $image"
}
# ===================
# WELCOME MESSAGE
# ===================
@ -125,7 +99,6 @@ pkgs.mkShell {
echo " dev-logs-db Tail database logs"
echo " dev-shell Shell into app container"
echo " dev-artisan <cmd> Run artisan command"
echo " prod-build [tag] Build and push prod image (default: latest)"
echo ""
echo "Services:"
echo " app Laravel + Vite http://localhost:8000"

View file

@ -48,7 +48,7 @@ public function test_update_modifies_article_processing_setting(): void
public function test_update_modifies_publishing_approvals_setting(): void
{
$response = $this->putJson('/api/v1/settings', [
'enable_publishing_approvals' => true,
'publishing_approvals_enabled' => true,
]);
$response->assertStatus(200)
@ -65,13 +65,13 @@ public function test_update_validates_boolean_values(): void
{
$response = $this->putJson('/api/v1/settings', [
'article_processing_enabled' => 'not-a-boolean',
'enable_publishing_approvals' => 'also-not-boolean',
'publishing_approvals_enabled' => 'also-not-boolean',
]);
$response->assertStatus(422)
->assertJsonValidationErrors([
'article_processing_enabled',
'enable_publishing_approvals'
'publishing_approvals_enabled'
]);
}
@ -89,7 +89,7 @@ public function test_update_accepts_partial_updates(): void
'article_processing_enabled' => true,
]
]);
// Should still have structure for both settings
$response->assertJsonStructure([
'data' => [
@ -98,4 +98,4 @@ public function test_update_accepts_partial_updates(): void
]
]);
}
}
}

View file

@ -84,11 +84,11 @@ public function test_sync_channel_posts_job_processes_successfully(): void
{
$channel = PlatformChannel::factory()->create();
$job = new SyncChannelPostsJob($channel);
// Test that job can be constructed and has correct properties
$this->assertEquals('sync', $job->queue);
$this->assertInstanceOf(SyncChannelPostsJob::class, $job);
// Don't actually run the job to avoid HTTP calls
$this->assertTrue(true);
}
@ -194,11 +194,10 @@ public function test_validate_article_listener_processes_new_article(): void
'full_article' => 'This is a test article about Belgium and Belgian politics.'
]);
$validationService = app(ValidationService::class);
$listener = new ValidateArticleListener();
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener->handle($event, $validationService);
$listener->handle($event);
$article->refresh();
$this->assertNotEquals('pending', $article->approval_status);

View file

@ -13,7 +13,7 @@ class NewArticleFetchedEventTest extends TestCase
{
use RefreshDatabase;
public function test_new_article_fetched_event_dispatched_on_article_creation(): void
public function test_new_article_fetched_event_dispatched_via_dispatch_method(): void
{
Event::fake([NewArticleFetched::class]);
@ -25,6 +25,8 @@ public function test_new_article_fetched_event_dispatched_on_article_creation():
'title' => 'Test Article',
]);
$article->dispatchFetchedEvent();
Event::assertDispatched(NewArticleFetched::class, function (NewArticleFetched $event) use ($article) {
return $event->article->id === $article->id;
});

View file

@ -34,11 +34,10 @@ public function test_listener_validates_article_and_dispatches_ready_to_publish_
'approval_status' => 'pending',
]);
$listener = new ValidateArticleListener();
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
$listener->handle($event);
$article->refresh();
@ -62,11 +61,10 @@ public function test_listener_skips_already_validated_articles(): void
'approval_status' => 'approved',
]);
$listener = new ValidateArticleListener();
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
$listener->handle($event);
Event::assertNotDispatched(ArticleReadyToPublish::class);
}
@ -90,11 +88,10 @@ public function test_listener_skips_articles_with_existing_publication(): void
'published_by' => 'test-user',
]);
$listener = new ValidateArticleListener();
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
$listener->handle($event);
Event::assertNotDispatched(ArticleReadyToPublish::class);
}
@ -115,11 +112,10 @@ public function test_listener_calls_validation_service(): void
'approval_status' => 'pending',
]);
$listener = new ValidateArticleListener();
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
$listener->handle($event);
// Verify that the article was processed by ValidationService
$article->refresh();

View file

@ -19,12 +19,12 @@ class ArticleTest extends TestCase
protected function setUp(): void
{
parent::setUp();
// Mock HTTP requests to prevent external calls
Http::fake([
'*' => Http::response('', 500)
]);
// Don't fake events globally - let individual tests control this
}
@ -180,18 +180,17 @@ public function test_feed_relationship(): void
$this->assertEquals($feed->id, $article->feed->id);
}
public function test_article_creation_fires_new_article_fetched_event(): void
public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void
{
$eventFired = false;
// Listen for the event using a closure
Event::listen(NewArticleFetched::class, function ($event) use (&$eventFired) {
$eventFired = true;
});
$feed = Feed::factory()->create();
Article::factory()->create(['feed_id' => $feed->id]);
Event::fake([NewArticleFetched::class]);
$this->assertTrue($eventFired, 'NewArticleFetched event was not fired');
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$article->dispatchFetchedEvent();
Event::assertDispatched(NewArticleFetched::class, function ($event) use ($article) {
return $event->article->id === $article->id;
});
}
}
}

View file

@ -3,20 +3,15 @@
namespace Tests\Unit\Modules\Lemmy\Services;
use App\Modules\Lemmy\Services\LemmyApiService;
use App\Models\PlatformChannelPost;
use App\Enums\PlatformEnum;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
use Mockery;
use Exception;
class LemmyApiServiceTest extends TestCase
{
protected function tearDown(): void
{
parent::tearDown();
}
use RefreshDatabase;
public function test_constructor_sets_instance(): void
{
@ -100,8 +95,6 @@ public function test_login_returns_null_on_unsuccessful_response(): void
'*' => Http::response(['error' => 'Invalid credentials'], 401)
]);
Log::shouldReceive('error')->twice(); // Once for HTTPS, once for HTTP fallback
$service = new LemmyApiService('lemmy.world');
$token = $service->login('user', 'wrong');
@ -114,19 +107,12 @@ public function test_login_handles_rate_limit_error(): void
'*' => Http::response('{"error":"rate_limit_error"}', 429)
]);
// Expecting 4 error logs:
// 1. 'Lemmy login failed' for HTTPS attempt
// 2. 'Lemmy login exception' for catching the rate limit exception on HTTPS
// 3. 'Lemmy login failed' for HTTP attempt
// 4. 'Lemmy login exception' for catching the rate limit exception on HTTP
Log::shouldReceive('error')->times(4);
$service = new LemmyApiService('lemmy.world');
$result = $service->login('user', 'pass');
// Since the exception is caught and HTTP is tried, then that also fails,
// the method returns null instead of throwing
$this->assertNull($result);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Rate limited');
$service->login('user', 'pass');
}
public function test_login_returns_null_when_jwt_missing_from_response(): void
@ -141,18 +127,18 @@ public function test_login_returns_null_when_jwt_missing_from_response(): void
$this->assertNull($token);
}
public function test_login_handles_exception_and_returns_null(): void
public function test_login_handles_exception_and_throws(): void
{
Http::fake(function () {
throw new Exception('Network error');
});
Log::shouldReceive('error')->twice();
$service = new LemmyApiService('lemmy.world');
$token = $service->login('user', 'pass');
$this->assertNull($token);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Connection failed');
$service->login('user', 'pass');
}
public function test_get_community_id_success(): void
@ -183,8 +169,6 @@ public function test_get_community_id_throws_on_unsuccessful_response(): void
'*' => Http::response('Not found', 404)
]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
@ -199,8 +183,6 @@ public function test_get_community_id_throws_when_community_not_in_response(): v
'*' => Http::response(['success' => true], 200)
]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
@ -234,21 +216,6 @@ public function test_sync_channel_posts_success(): void
], 200)
]);
Log::shouldReceive('info')->once()->with('Synced channel posts', Mockery::any());
$mockPost = Mockery::mock('alias:' . PlatformChannelPost::class);
$mockPost->shouldReceive('storePost')
->twice()
->with(
PlatformEnum::LEMMY,
Mockery::any(),
'test-community',
Mockery::any(),
Mockery::any(),
Mockery::any(),
Mockery::any()
);
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
@ -258,6 +225,25 @@ public function test_sync_channel_posts_success(): void
&& str_contains($request->url(), 'limit=50')
&& str_contains($request->url(), 'sort=New');
});
// Verify posts were stored in the database
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '1',
'url' => 'https://example.com/1',
'title' => 'Post 1',
]);
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '2',
'url' => 'https://example.com/2',
'title' => 'Post 2',
]);
}
public function test_sync_channel_posts_handles_unsuccessful_response(): void
@ -266,12 +252,11 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
'*' => Http::response('Error', 500)
]);
Log::shouldReceive('warning')->once()->with('Failed to sync channel posts', Mockery::any());
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
Http::assertSentCount(1);
$this->assertDatabaseCount('platform_channel_posts', 0);
}
public function test_sync_channel_posts_handles_exception(): void
@ -280,8 +265,6 @@ public function test_sync_channel_posts_handles_exception(): void
throw new Exception('Network error');
});
Log::shouldReceive('error')->once()->with('Exception while syncing channel posts', Mockery::any());
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
@ -354,8 +337,6 @@ public function test_create_post_throws_on_unsuccessful_response(): void
'*' => Http::response('Forbidden', 403)
]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
@ -393,8 +374,6 @@ public function test_get_languages_returns_empty_array_on_failure(): void
'*' => Http::response('Error', 500)
]);
Log::shouldReceive('warning')->once();
$service = new LemmyApiService('lemmy.world');
$languages = $service->getLanguages();
@ -407,8 +386,6 @@ public function test_get_languages_handles_exception(): void
throw new Exception('Network error');
});
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world');
$languages = $service->getLanguages();

View file

@ -9,6 +9,7 @@
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher;
@ -105,7 +106,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
@ -151,7 +152,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
@ -296,4 +297,162 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
$this->assertDatabaseHas('article_publications', ['post_id' => 300]);
$this->assertDatabaseCount('article_publications', 1);
}
public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/article-1',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Simulate the URL already being posted to this channel (synced from Lemmy)
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'999',
'https://example.com/article-1',
'Different Title',
);
// Publisher should never be called
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']);
// Assert
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0);
}
public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/article-new-url',
'title' => 'Breaking News: Something Happened',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Simulate the same title already posted with a different URL
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'888',
'https://example.com/different-url',
'Breaking News: Something Happened',
);
// Publisher should never be called
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']);
// Assert
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0);
}
public function test_publish_proceeds_when_no_duplicate_exists(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/unique-article',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Existing post in the channel has a completely different URL and title
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'777',
'https://example.com/other-article',
'Totally Different Title',
);
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldReceive('publishToChannel')
->once()
->andReturn(['post_view' => ['post' => ['id' => 456]]]);
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']);
// Assert
$this->assertCount(1, $result);
$this->assertDatabaseHas('article_publications', [
'article_id' => $article->id,
'post_id' => 456,
]);
}
}

View file

@ -3,15 +3,17 @@
namespace Tests\Unit\Services;
use App\Services\SystemStatusService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Illuminate\Support\Facades\Http;
class SystemStatusServiceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Mock HTTP requests to prevent external calls
Http::fake([
'*' => Http::response('', 500)
@ -34,11 +36,11 @@ public function test_get_system_status_returns_correct_structure(): void
$this->assertArrayHasKey('status', $status);
$this->assertArrayHasKey('status_class', $status);
$this->assertArrayHasKey('reasons', $status);
// Without database setup, system should be disabled
$this->assertFalse($status['is_enabled']);
$this->assertEquals('Disabled', $status['status']);
$this->assertEquals('text-red-600', $status['status_class']);
$this->assertIsArray($status['reasons']);
}
}
}