Compare commits
6 commits
3e23dad5c5
...
8ea0365b8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ea0365b8b | |||
| 71e7611e65 | |||
| c75a1c8cf0 | |||
| 8bc6e99f96 | |||
| d3c44a4952 | |||
| b574785d1e |
37 changed files with 675 additions and 400 deletions
20
.env.example
20
.env.example
|
|
@ -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
|
||||
|
||||
|
|
|
|||
41
.forgejo/workflows/build.yml
Normal file
41
.forgejo/workflows/build.yml
Normal 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
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@
|
|||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
64
app/Listeners/PublishApprovedArticleListener.php
Normal file
64
app/Listeners/PublishApprovedArticleListener.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -1,7 +1 @@
|
|||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
import "./bootstrap";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
27
shell.nix
27
shell.nix
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue