diff --git a/.env.example b/.env.example index 235552aa..eb09d377 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,13 @@ DISCORD_CLIENT_SECRET= DISCORD_BOT_TOKEN= DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback" +# Reddit (create an app at https://www.reddit.com/prefs/apps — choose "web app") +REDDIT_ENABLED=true +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= +REDDIT_CLIENT_REDIRECT="${APP_URL}/accounts/reddit/callback" +REDDIT_SCOPES="identity,read,submit,flair,mysubreddits" + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index ce363040..7d21a0d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,6 +336,10 @@ Vue components must have a single root element. - Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes. - Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config. +## HTTP User-Agent + +- NEVER hardcode a `User-Agent` string for outbound HTTP, and NEVER add a per-platform or per-feature user-agent config key. There is ONE source of truth: `config('app.user_agent')` (backed by `env('TRYPOST_USER_AGENT', ...)` in `config/app.php`). Every outbound request that needs a UA — automation webhook/http nodes, social provider APIs (Reddit, etc.), anything — reads `config('app.user_agent')`. + ## TryPost.it Documentation - All our documentation to final user it's under https://docs.trypost.it diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php index 0036cdc7..6f555cb6 100644 --- a/app/Actions/Automation/Node/RunHttpRequestNode.php +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -364,7 +364,7 @@ private function buildRequest(array $config, array $context): PendingRequest $request = $request->withHeaders($headers); } - return $request->withUserAgent(config('trypost.user_agent')); + return $request->withUserAgent(config('app.user_agent')); } /** diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php index 0b5498c9..3d743b7b 100644 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -74,7 +74,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult try { $response = Http::withHeaders($headers) - ->withUserAgent(config('trypost.user_agent')) + ->withUserAgent(config('app.user_agent')) ->send($method, $url, ['json' => $payload]); } catch (Throwable $e) { return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [ diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0c525df9..1d28b531 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -56,6 +56,9 @@ enum ContentType: string // Discord case DiscordMessage = 'discord_message'; + // Reddit + case RedditPost = 'reddit_post'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -85,6 +88,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::RedditPost => 'Post', }; } @@ -109,6 +113,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::RedditPost => SocialPlatform::Reddit, }; } @@ -179,6 +184,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::RedditPost => 10, }; } @@ -200,6 +206,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::RedditPost => false, }; } @@ -240,6 +247,7 @@ public function requiresMedia(): bool self::FacebookPost => false, self::InstagramFeed => false, self::DiscordMessage => false, + self::RedditPost => false, default => true, }; } @@ -322,6 +330,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::Reddit => self::RedditPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 0d645c97..40e523f5 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -22,6 +22,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case Reddit = 'reddit'; public function label(): string { @@ -40,6 +41,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::Reddit => 'Reddit', }; } @@ -59,6 +61,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::Reddit => '#FF4500', }; } @@ -77,6 +80,7 @@ public function allowedMediaTypes(): array self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], + self::Reddit => [MediaType::Image], }; } @@ -95,6 +99,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::Reddit => 10, }; } @@ -135,6 +140,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::Reddit => 40000, }; } @@ -180,6 +186,7 @@ public function recommendedAiContentLength(): int self::Telegram => 400, // Discord — conversational community posts read best when concise self::Discord => 280, + self::Reddit => 500, }; } @@ -203,6 +210,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::Reddit => ['submit'], }; } @@ -221,6 +229,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::Reddit => true, }; } diff --git a/app/Exceptions/Social/RedditPublishException.php b/app/Exceptions/Social/RedditPublishException.php new file mode 100644 index 00000000..a2eb223f --- /dev/null +++ b/app/Exceptions/Social/RedditPublishException.php @@ -0,0 +1,96 @@ +userMessage = $userMessage; + $this->category = $category; + $this->platformErrorCode = $platformErrorCode; + $this->rawResponse = $rawResponse; + + RuntimeException::__construct($userMessage, 0, $previous); + } + + public static function fromApiResponse(mixed $response): static + { + /** @var Response $response */ + $status = $response->status(); + $rawResponse = $response->body(); + $json = $response->json() ?? []; + + if ($status === 401 || $status === 403) { + return new static( + userMessage: 'Reddit rejected the request. Check that the account is connected and has permission to post.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Reddit rate limit reached. Please try again shortly.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Reddit is temporarily unavailable. Please try again later.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + $message = self::extractApiMessage($json, $status); + + return new static( + userMessage: $message, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + /** + * @param array $json + */ + private static function extractApiMessage(array $json, int $status): string + { + foreach ([ + data_get($json, 'json.errors.0.1'), + data_get($json, 'errors.0.1'), + data_get($json, 'explanation'), + data_get($json, 'reason'), + data_get($json, 'message'), + ] as $candidate) { + if (is_string($candidate) && $candidate !== '') { + return $candidate; + } + } + + return "An unknown Reddit error occurred (HTTP {$status})."; + } + + public function platform(): string + { + return 'reddit'; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index aee64aad..818ce8ba 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -11,6 +11,7 @@ use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; +use App\Services\Social\Reddit\RedditAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; @@ -36,6 +37,7 @@ class AnalyticsController extends Controller Platform::Pinterest, Platform::YouTube, Platform::Telegram, + Platform::Reddit, ]; public function index(Request $request): Response @@ -80,6 +82,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), + Platform::Reddit => app(RedditAnalytics::class)->getMetrics($account), default => [], }; diff --git a/app/Http/Controllers/App/RedditController.php b/app/Http/Controllers/App/RedditController.php new file mode 100644 index 00000000..f30aa624 --- /dev/null +++ b/app/Http/Controllers/App/RedditController.php @@ -0,0 +1,58 @@ +authorizeRedditAccount($request, $account); + + $query = trim((string) $request->query('q', '')); + + return response()->json([ + 'data' => $query === '' ? [] : rescue( + fn () => $this->client->searchSubreddits($account, $query), + [], + report: false, + ), + ]); + } + + public function restrictions(Request $request, SocialAccount $account, string $subreddit): JsonResponse + { + $this->authorizeRedditAccount($request, $account); + + return response()->json([ + 'data' => rescue( + fn () => $this->client->restrictions($account, $subreddit), + ['allowed_types' => ['self', 'link', 'image'], 'flair_required' => false, 'flairs' => []], + report: false, + ), + ]); + } + + private function authorizeRedditAccount(Request $request, SocialAccount $account): void + { + $workspace = $request->user()->currentWorkspace; + + abort_unless( + $workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Reddit, + Response::HTTP_FORBIDDEN, + ); + + $this->authorize('view', $workspace); + } +} diff --git a/app/Http/Controllers/Auth/RedditController.php b/app/Http/Controllers/Auth/RedditController.php new file mode 100644 index 00000000..b23338e8 --- /dev/null +++ b/app/Http/Controllers/Auth/RedditController.php @@ -0,0 +1,38 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.reddit.scopes')); + } + + public function callback(Request $request): View + { + return $this->handleCallback($request, $this->platform, $this->driver); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5bf96054..0d2f1410 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -26,6 +26,7 @@ use App\Services\Social\LinkedInPublisher; use App\Services\Social\MastodonPublisher; use App\Services\Social\PinterestPublisher; +use App\Services\Social\Reddit\RedditPublisher; use App\Services\Social\Telegram\TelegramPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; @@ -223,7 +224,7 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|RedditPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -239,6 +240,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::Reddit => app(RedditPublisher::class), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..0064d974 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -126,6 +126,7 @@ protected function profileUrl(): Attribute ? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}" : null, SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null, + SocialPlatform::Reddit => $username ? "https://www.reddit.com/user/{$username}" : null, default => null, }; }, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ea3193d..d40e6311 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,6 +34,7 @@ use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; +use App\Socialite\RedditProvider; use Carbon\CarbonImmutable; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; @@ -193,6 +194,12 @@ protected function configureSocialite(): void return Socialite::buildProvider(DiscordProvider::class, $config); }); + Socialite::extend('reddit', function ($app) { + $config = $app['config']['services.reddit']; + + return Socialite::buildProvider(RedditProvider::class, $config); + }); + Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class); diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 65de20e3..b1181c36 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -69,8 +69,8 @@ public function optimizeImage(string $filePath, Platform $platform): string file_put_contents($tempFile, (string) $encoded); // If still above the platform size budget, iteratively shrink DIMENSIONS - // (not quality) by 10 % per step until it fits. Postiz-style, preserves - // pixel quality while lowering the byte count. + // (not quality) by 10 % per step until it fits, preserving pixel quality + // while lowering the byte count. while (filesize($tempFile) > $maxSize) { $newWidth = (int) ($image->width() * 0.9); $newHeight = (int) ($image->height() * 0.9); @@ -208,6 +208,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Reddit => [ + 'max_width' => 2048, + 'max_size' => 20 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305..935513f5 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -14,6 +14,7 @@ use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\MastodonAnalytics; use App\Services\Social\PinterestAnalytics; +use App\Services\Social\Reddit\RedditAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\XAnalytics; @@ -74,6 +75,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Reddit => app(RedditAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], }); } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 92f62433..a329c6b8 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -70,6 +70,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::Reddit => $this->verifyReddit($account), }; } @@ -103,6 +104,7 @@ public function refreshToken(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), + Platform::Reddit => $this->refreshRedditToken($account), // Facebook / InstagramFacebook use Page tokens that don't expire. // Mastodon tokens don't expire either. default => null, @@ -537,4 +539,42 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } + + private function verifyReddit(SocialAccount $account): bool + { + $response = Http::withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('app.user_agent')]) + ->get(config('trypost.platforms.reddit.api').'/api/v1/me'); + + if (in_array($response->status(), [401, 403], true)) { + throw new TokenExpiredException('Reddit access token is invalid or expired'); + } + + return $response->successful(); + } + + private function refreshRedditToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Reddit account'); + } + + $response = TokenRefreshClient::for(Platform::Reddit)->send(fn () => Http::asForm() + ->withBasicAuth((string) config('services.reddit.client_id'), (string) config('services.reddit.client_secret')) + ->withHeaders(['User-Agent' => (string) config('app.user_agent')]) + ->post(config('trypost.platforms.reddit.oauth_api').'/access_token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds((int) data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } } diff --git a/app/Services/Social/Reddit/RedditAnalytics.php b/app/Services/Social/Reddit/RedditAnalytics.php new file mode 100644 index 00000000..c678b7b2 --- /dev/null +++ b/app/Services/Social/Reddit/RedditAnalytics.php @@ -0,0 +1,69 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + try { + $karma = $this->client->me($account); + } catch (Throwable) { + return []; + } + + return [ + ['label' => __('analytics.metrics.karma'), 'value' => data_get($karma, 'total_karma', 0)], + ]; + } + + /** + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + + $fullnames = collect(explode(',', (string) $postPlatform->platform_post_id)) + ->map(fn ($id) => trim($id)) + ->filter() + ->values() + ->all(); + + if (! $account || $fullnames === []) { + return []; + } + + try { + $info = $this->client->info($account, $fullnames); + } catch (Throwable) { + return []; + } + + if ($info === []) { + return []; + } + + return [ + ['label' => __('analytics.metrics.upvotes'), 'value' => (int) collect($info)->sum('score'), 'kind' => 'reaction'], + ['label' => __('analytics.metrics.comments'), 'value' => (int) collect($info)->sum('num_comments'), 'kind' => 'comments'], + ]; + } +} diff --git a/app/Services/Social/Reddit/RedditClient.php b/app/Services/Social/Reddit/RedditClient.php new file mode 100644 index 00000000..6be3a479 --- /dev/null +++ b/app/Services/Social/Reddit/RedditClient.php @@ -0,0 +1,140 @@ + + */ + public function searchSubreddits(SocialAccount $account, string $query): array + { + $response = $this->reddit($account)->get($this->url('/subreddits/search'), [ + 'q' => $query, + 'show' => 'public', + 'sort' => 'activity', + 'show_users' => 'false', + 'limit' => 10, + ]); + + return collect(data_get($response->json(), 'data.children', [])) + ->map(fn ($child) => [ + 'name' => (string) data_get($child, 'data.display_name'), + 'title' => (string) data_get($child, 'data.title'), + 'subscribers' => (int) data_get($child, 'data.subscribers', 0), + 'over_18' => (bool) data_get($child, 'data.over18', false), + ]) + ->filter(fn ($sub) => $sub['name'] !== '') + ->values() + ->all(); + } + + /** + * @return array{allowed_types: list, flair_required: bool, flairs: list} + */ + public function restrictions(SocialAccount $account, string $subreddit): array + { + $about = $this->reddit($account)->get($this->url("/r/{$subreddit}/about"))->json(); + $submissionType = (string) data_get($about, 'data.submission_type', 'any'); + $allowImages = (bool) data_get($about, 'data.allow_images', true); + + $allowedTypes = match ($submissionType) { + 'self' => ['self'], + 'link' => ['link'], + default => ['self', 'link'], + }; + + if ($allowImages) { + $allowedTypes[] = 'image'; + } + + $flairRequired = (bool) data_get( + $this->reddit($account)->get($this->url("/api/v1/{$subreddit}/post_requirements"))->json(), + 'is_flair_required', + false + ); + + return [ + 'allowed_types' => $allowedTypes, + 'flair_required' => $flairRequired, + 'flairs' => $this->flairs($account, $subreddit), + ]; + } + + /** + * @return list + */ + public function flairs(SocialAccount $account, string $subreddit): array + { + try { + return collect($this->reddit($account)->get($this->url("/r/{$subreddit}/api/link_flair_v2"))->json()) + ->map(fn ($flair) => [ + 'id' => (string) data_get($flair, 'id'), + 'text' => (string) data_get($flair, 'text'), + ]) + ->filter(fn ($flair) => $flair['id'] !== '') + ->values() + ->all(); + } catch (Throwable) { + return []; + } + } + + /** + * @param list $fullnames Reddit fullnames, e.g. ['t3_abc123']. + * @return array + */ + public function info(SocialAccount $account, array $fullnames): array + { + if ($fullnames === []) { + return []; + } + + $response = $this->reddit($account)->get($this->url('/api/info'), ['id' => implode(',', $fullnames)]); + + return collect(data_get($response->json(), 'data.children', [])) + ->mapWithKeys(fn ($child) => [ + (string) data_get($child, 'data.name') => [ + 'score' => (int) data_get($child, 'data.score', 0), + 'num_comments' => (int) data_get($child, 'data.num_comments', 0), + 'url' => (string) data_get($child, 'data.url', ''), + ], + ]) + ->all(); + } + + /** + * @return array{link_karma: int, comment_karma: int, total_karma: int} + */ + public function me(SocialAccount $account): array + { + $data = $this->reddit($account)->get($this->url('/api/v1/me'))->json(); + + return [ + 'link_karma' => (int) data_get($data, 'link_karma', 0), + 'comment_karma' => (int) data_get($data, 'comment_karma', 0), + 'total_karma' => (int) data_get($data, 'total_karma', 0), + ]; + } + + private function url(string $path): string + { + return (string) config('trypost.platforms.reddit.api').$path; + } + + private function reddit(SocialAccount $account): PendingRequest + { + return $this->socialHttp() + ->withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('app.user_agent')]); + } +} diff --git a/app/Services/Social/Reddit/RedditPublisher.php b/app/Services/Social/Reddit/RedditPublisher.php new file mode 100644 index 00000000..8269e637 --- /dev/null +++ b/app/Services/Social/Reddit/RedditPublisher.php @@ -0,0 +1,317 @@ +socialAccount; + + $subreddits = collect((array) data_get($postPlatform->meta, 'subreddits', [])) + ->filter(fn ($s) => filled(data_get($s, 'name'))) + ->values(); + + if ($subreddits->isEmpty()) { + throw new RedditPublishException( + userMessage: 'No subreddit selected for this Reddit post.', + category: ErrorCategory::Unknown, + ); + } + + $text = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : ''; + + $imageMedia = $postPlatform->post->mediaItems + ->filter(fn (MediaItem $item) => $item->isImage()) + ->take($postPlatform->platform->maxImages()) + ->values(); + + /** @var list $results */ + $results = []; + + foreach ($subreddits as $index => $sub) { + if ($index > 0) { + usleep(self::SUBMIT_DELAY_MICROSECONDS); + } + + try { + $results[] = $this->submitOne($account, $sub, $text, $imageMedia); + } catch (Throwable $e) { + $this->persistResults($postPlatform, $results); + + throw new RedditPublishException( + userMessage: 'Published to '.count($results).' subreddit(s); failed on r/'.data_get($sub, 'name').': '.$e->getMessage(), + category: ErrorCategory::Unknown, + previous: $e, + ); + } + } + + $this->persistResults($postPlatform, $results); + + return [ + 'id' => implode(',', array_column($results, 'id')), + 'url' => implode(',', array_filter(array_column($results, 'url'))), + ]; + } + + /** + * @param array $sub + * @param Collection $imageMedia + * @return array{subreddit: string, id: string, url: string} + */ + private function submitOne(SocialAccount $account, array $sub, string $text, Collection $imageMedia): array + { + $name = (string) data_get($sub, 'name'); + $type = (string) data_get($sub, 'type', 'self'); + $title = trim((string) data_get($sub, 'title')); + + if ($title === '') { + throw new RedditPublishException( + userMessage: "A title is required to post to r/{$name}.", + category: ErrorCategory::Unknown, + ); + } + + if ($type === 'image' && $imageMedia->count() >= 2) { + return $this->submitGallery($account, $sub, $name, $title, $imageMedia); + } + + $payload = array_filter([ + 'api_type' => 'json', + 'sr' => $name, + 'title' => $title, + 'kind' => $this->kind($type), + 'nsfw' => (bool) data_get($sub, 'nsfw', false) ? 'true' : null, + 'spoiler' => (bool) data_get($sub, 'spoiler', false) ? 'true' : null, + 'flair_id' => data_get($sub, 'flair_id') ?: null, + 'flair_text' => data_get($sub, 'flair_text') ?: null, + ], fn ($v) => $v !== null); + + if ($type === 'self') { + $payload['text'] = $text; + } elseif ($type === 'link') { + $payload['url'] = (string) data_get($sub, 'url'); + } else { + $payload['url'] = $this->uploadSingleImage($account, $imageMedia); + } + + $response = $this->reddit($account)->asForm()->post($this->url('/api/submit'), $payload); + + $this->assertOk($response); + + $id = (string) data_get($response->json(), 'json.data.id'); + $fullname = (string) data_get($response->json(), 'json.data.name'); + $fullname = $fullname !== '' ? $fullname : ($id !== '' ? "t3_{$id}" : ''); + + return [ + 'subreddit' => $name, + 'id' => $fullname, + 'url' => $this->resolveUrl($account, $fullname), + ]; + } + + /** + * @param array $sub + * @param Collection $imageMedia + * @return array{subreddit: string, id: string, url: string} + */ + private function submitGallery(SocialAccount $account, array $sub, string $name, string $title, Collection $imageMedia): array + { + $imageMedia->each(function (MediaItem $item) { + if ($item->isVideo()) { + throw new RedditPublishException( + userMessage: 'Reddit video posts are not supported yet.', + category: ErrorCategory::MediaFormat, + ); + } + }); + + $items = $imageMedia->map(function (MediaItem $item) use ($account) { + ['asset_id' => $assetId] = $this->leaseAndUpload($account, $item); + + return ['media_id' => $assetId, 'caption' => '', 'outbound_url' => '']; + })->values()->all(); + + $payload = array_filter([ + 'api_type' => 'json', + 'sr' => $name, + 'title' => $title, + 'nsfw' => (bool) data_get($sub, 'nsfw', false) ? 'true' : null, + 'spoiler' => (bool) data_get($sub, 'spoiler', false) ? 'true' : null, + 'flair_id' => data_get($sub, 'flair_id') ?: null, + 'flair_text' => data_get($sub, 'flair_text') ?: null, + 'items' => json_encode($items), + ], fn ($v) => $v !== null); + + $response = $this->reddit($account)->asForm()->post($this->url('/api/submit_gallery_post.json'), $payload); + + $this->assertOk($response); + + $id = (string) data_get($response->json(), 'json.data.id'); + $fullname = (string) data_get($response->json(), 'json.data.name'); + $fullname = $fullname !== '' ? $fullname : ($id !== '' ? "t3_{$id}" : ''); + + return [ + 'subreddit' => $name, + 'id' => $fullname, + 'url' => $this->resolveUrl($account, $fullname), + ]; + } + + /** + * @param Collection $imageMedia + */ + private function uploadSingleImage(SocialAccount $account, Collection $imageMedia): string + { + $item = $imageMedia->first() ?? throw new RedditPublishException( + userMessage: 'No image attached for this Reddit image post.', + category: ErrorCategory::MediaFormat, + ); + + if ($item->isVideo()) { + throw new RedditPublishException( + userMessage: 'Reddit video posts are not supported yet.', + category: ErrorCategory::MediaFormat, + ); + } + + ['url' => $hostedUrl] = $this->leaseAndUpload($account, $item); + + return $hostedUrl; + } + + /** + * Leases a Reddit media asset, uploads the file to S3, and returns both + * the asset_id (needed for gallery submissions) and the hosted URL + * (needed for single-image submissions). + * + * @return array{asset_id: string, url: string} + */ + private function leaseAndUpload(SocialAccount $account, MediaItem $item): array + { + $mime = (string) ($item->mime_type ?: 'image/jpeg'); + $filename = $item->original_filename ?: (basename((string) $item->path) ?: 'image'); + + $lease = $this->reddit($account)->asForm()->post($this->url('/api/media/asset'), [ + 'filepath' => $filename, + 'mimetype' => $mime, + ]); + $this->assertOk($lease); + + $assetId = (string) data_get($lease->json(), 'asset.asset_id'); + $action = 'https:'.preg_replace('#^https?:#', '', (string) data_get($lease->json(), 'args.action')); + $fields = collect((array) data_get($lease->json(), 'args.fields')) + ->mapWithKeys(fn ($f) => [(string) data_get($f, 'name') => (string) data_get($f, 'value')]) + ->all(); + + $bytes = Http::timeout(120)->get($item->url)->body(); + + $upload = Http::asMultipart(); + foreach ($fields as $fieldName => $value) { + $upload = $upload->attach($fieldName, $value); + } + $upload = $upload->attach('file', $bytes, $filename); + $s3 = $upload->post($action); + + if ($s3->failed()) { + throw new RedditPublishException( + userMessage: 'Failed to upload the image to Reddit.', + category: ErrorCategory::MediaFormat, + ); + } + + if (preg_match('/(.*?)<\/Location>/', $s3->body(), $m)) { + return ['asset_id' => $assetId, 'url' => html_entity_decode($m[1])]; + } + + return ['asset_id' => $assetId, 'url' => rtrim($action, '/').'/'.($fields['key'] ?? $filename)]; + } + + private function resolveUrl(SocialAccount $account, string $fullname): string + { + if ($fullname === '') { + return ''; + } + + return (string) data_get($this->client->info($account, [$fullname]), "{$fullname}.url", ''); + } + + private function kind(string $type): string + { + return match ($type) { + 'link' => 'link', + 'image' => 'image', + default => 'self', + }; + } + + private function assertOk(Response $response): void + { + if ($response->failed()) { + throw RedditPublishException::fromApiResponse($response); + } + + $errors = (array) data_get($response->json(), 'json.errors', []); + + if ($errors !== []) { + throw new RedditPublishException( + userMessage: (string) (data_get($errors, '0.1') ?: 'Reddit rejected the submission.'), + category: ErrorCategory::Unknown, + ); + } + } + + /** + * @param list $results + */ + private function persistResults(PostPlatform $postPlatform, array $results): void + { + $postPlatform->update(['meta' => array_merge((array) $postPlatform->meta, ['results' => $results])]); + } + + private function url(string $path): string + { + return (string) config('trypost.platforms.reddit.api').$path; + } + + private function reddit(SocialAccount $account): PendingRequest + { + return $this->socialHttp() + ->withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('app.user_agent')]); + } +} diff --git a/app/Socialite/RedditProvider.php b/app/Socialite/RedditProvider.php new file mode 100644 index 00000000..60fdb667 --- /dev/null +++ b/app/Socialite/RedditProvider.php @@ -0,0 +1,95 @@ +buildAuthUrlFromBase(config('trypost.platforms.reddit.oauth_api').'/authorize', $state); + } + + /** + * @return array + */ + protected function getCodeFields($state = null): array + { + return array_merge(parent::getCodeFields($state), [ + 'duration' => 'permanent', + ]); + } + + protected function getTokenUrl(): string + { + return config('trypost.platforms.reddit.oauth_api').'/access_token'; + } + + /** + * Reddit's token endpoint authenticates the CLIENT via Basic auth, so the + * code grant is sent with the app credentials, not a bearer token. + * + * @return array + */ + public function getAccessTokenResponse($code): array + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + 'auth' => [config('services.reddit.client_id'), config('services.reddit.client_secret')], + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => config('app.user_agent'), + ], + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectUrl, + ], + ]); + + return (array) json_decode((string) $response->getBody(), true); + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get(config('trypost.platforms.reddit.api').'/api/v1/me', [ + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'User-Agent' => config('app.user_agent'), + ], + ]); + + return (array) json_decode((string) $response->getBody(), true); + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user): User + { + $icon = (string) data_get($user, 'icon_img', ''); + $icon = $icon !== '' ? strtok($icon, '?') : null; + + return (new User)->setRaw($user)->map([ + 'id' => data_get($user, 'id'), + 'nickname' => data_get($user, 'name'), + 'name' => data_get($user, 'name'), + 'avatar' => $icon, + ]); + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index d7a12682..11ba2af4 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -71,6 +71,20 @@ public static function rules(): array 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + + // Reddit + 'platforms.*.meta.subreddits' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.subreddits.*.name' => ['required', 'string'], + 'platforms.*.meta.subreddits.*.title' => ['required', 'string', 'max:300'], + 'platforms.*.meta.subreddits.*.type' => ['required', 'string', Rule::in(['self', 'link', 'image'])], + 'platforms.*.meta.subreddits.*.url' => ['sometimes', 'nullable', 'url'], + 'platforms.*.meta.subreddits.*.flair_id' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.subreddits.*.flair_text' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.subreddits.*.flair_required' => ['sometimes', 'boolean'], + 'platforms.*.meta.subreddits.*.allowed_types' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.subreddits.*.allowed_types.*' => ['string'], + 'platforms.*.meta.subreddits.*.nsfw' => ['sometimes', 'boolean'], + 'platforms.*.meta.subreddits.*.spoiler' => ['sometimes', 'boolean'], ]; } @@ -130,11 +144,42 @@ public static function assertStoredPostPublishable(Post $post): void */ private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { + $reddit = $platform === Platform::Reddit ? self::redditMetaViolation($meta) : null; + return match (true) { + $reddit !== null => $reddit, $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], default => null, }; } + + /** + * @return array{0: string, 1: string}|null + */ + private static function redditMetaViolation(mixed $meta): ?array + { + $subreddits = (array) data_get($meta, 'subreddits', []); + + if ($subreddits === []) { + return ['subreddits', trans('posts.form.reddit.subreddit_required')]; + } + + foreach ($subreddits as $sub) { + if (blank(data_get($sub, 'title'))) { + return ['subreddits', trans('posts.form.reddit.title_required')]; + } + + if (data_get($sub, 'type') === 'link' && blank(data_get($sub, 'url'))) { + return ['subreddits', trans('posts.form.reddit.url_required')]; + } + + if (data_get($sub, 'flair_required') && blank(data_get($sub, 'flair_id'))) { + return ['subreddits', trans('posts.form.reddit.flair_required')]; + } + } + + return null; + } } diff --git a/config/app.php b/config/app.php index e8080f34..6e1f4277 100644 --- a/config/app.php +++ b/config/app.php @@ -69,6 +69,19 @@ 'webhook_url' => env('WEBHOOK_URL', env('APP_URL', 'https://app.trypost.it')), + /* + |-------------------------------------------------------------------------- + | Outbound User-Agent + |-------------------------------------------------------------------------- + | + | Single, branded User-Agent for every outbound HTTP request the app makes + | (automation webhook/http nodes, social provider APIs, etc.). Centralized + | here so there is one source of truth; self-hosters can override it. + | + */ + + 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/config/services.php b/config/services.php index e72632d6..6489cf2a 100644 --- a/config/services.php +++ b/config/services.php @@ -118,6 +118,13 @@ 'redirect' => env('DISCORD_CLIENT_REDIRECT'), ], + // Reddit + 'reddit' => [ + 'client_id' => env('REDDIT_CLIENT_ID'), + 'client_secret' => env('REDDIT_CLIENT_SECRET'), + 'redirect' => env('REDDIT_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index 16bac3f5..ff800dde 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -59,19 +59,6 @@ | */ - /* - |-------------------------------------------------------------------------- - | Outbound User-Agent - |-------------------------------------------------------------------------- - | - | Branded User-Agent applied to outbound HTTP from automation nodes - | (webhook + http_request) so recipients know the request came from - | TryPost.it. Self-hosters can override it. - | - */ - - 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), - 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), @@ -176,6 +163,12 @@ 'permissions' => env('DISCORD_PERMISSIONS', '248832'), 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), ], + 'reddit' => [ + 'enabled' => env('REDDIT_ENABLED', true), + 'api' => env('REDDIT_API', 'https://oauth.reddit.com'), + 'oauth_api' => env('REDDIT_OAUTH_API', 'https://www.reddit.com/api/v1'), + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('REDDIT_SCOPES', 'identity,read,submit,flair,mysubreddits'))))), + ], ], ]; diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970..7cf8202c 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -163,6 +163,14 @@ public function discord(): static ]); } + public function reddit(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Reddit, + 'content_type' => ContentType::RedditPost, + ]); + } + public function facebookReel(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12..311412f5 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -154,6 +154,20 @@ public function telegram(): static ]); } + public function reddit(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Reddit, + 'scopes' => Platform::Reddit->requiredPublishScopes(), + 'platform_user_id' => (string) $this->faker->numberBetween(100000, 999999), + 'username' => $this->faker->userName(), + 'display_name' => $this->faker->name(), + 'access_token' => 'reddit-access-token', + 'refresh_token' => 'reddit-refresh-token', + 'token_expires_at' => now()->addHour(), + ]); + } + public function discord(): static { return $this->state(fn (array $attributes) => [ diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 0c31fd1a..88ff7bd7 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -138,6 +138,13 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= TELEGRAM_WEBHOOK_SECRET= +# Reddit (create an app at https://www.reddit.com/prefs/apps — choose "web app") +REDDIT_ENABLED=true +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= +REDDIT_CLIENT_REDIRECT="${APP_URL}/accounts/reddit/callback" +REDDIT_SCOPES="identity,read,submit,flair,mysubreddits" + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 52871353..e4b2b455 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'reddit' => 'Connect your Reddit account', ], 'disconnect_modal' => [ diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 99c8190c..2737dda8 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Following', 'impressions' => 'Impressions', 'interactions' => 'Interactions', + 'karma' => 'Karma', 'likes' => 'Likes', 'members' => 'Members', 'minutes_watched' => 'Minutes Watched', @@ -45,6 +46,7 @@ 'saves' => 'Saves', 'shares' => 'Shares', 'subscribers' => 'Subscribers', + 'upvotes' => 'Upvotes', 'subscribers_gained' => 'Subscribers Gained', 'subscribers_lost' => 'Subscribers Lost', 'total_likes' => 'Total Likes', diff --git a/lang/en/posts.php b/lang/en/posts.php index 6fe57b73..f35ab489 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -181,6 +181,29 @@ 'embed_image' => 'Image URL', 'embed_color' => 'Color', ], + 'reddit' => [ + 'settings' => 'Reddit Settings', + 'posting_to' => 'Posting to', + 'searching' => 'Searching…', + 'add_subreddit' => 'Add subreddit', + 'remove_subreddit' => 'Remove', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Search subreddits…', + 'title' => 'Title', + 'post_type' => 'Post type', + 'type_self' => 'Text', + 'type_link' => 'Link', + 'type_image' => 'Image', + 'url' => 'Link URL', + 'flair' => 'Flair', + 'no_flair' => 'No flair', + 'nsfw' => 'Mark as NSFW', + 'spoiler' => 'Mark as spoiler', + 'subreddit_required' => 'Add at least one subreddit to publish this post.', + 'title_required' => 'Each subreddit needs a title.', + 'url_required' => 'A link post needs a URL.', + 'flair_required' => 'This subreddit requires a flair.', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -512,6 +535,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'reddit_post' => [ + 'label' => 'Post', + 'description' => 'Text, link, or image post to a subreddit', + ], ], 'platforms' => [ @@ -616,6 +643,7 @@ 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', 'discord_message' => 'Discord Message', + 'reddit_post' => 'Reddit Post', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3a8ed353..200a24f8 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'reddit' => 'Conecta tu cuenta de Reddit', ], 'disconnect_modal' => [ diff --git a/lang/es/analytics.php b/lang/es/analytics.php index da4d2a40..f258cbbd 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Siguiendo', 'impressions' => 'Impresiones', 'interactions' => 'Interacciones', + 'karma' => 'Karma', 'likes' => 'Me gusta', 'members' => 'Miembros', 'minutes_watched' => 'Minutos Vistos', @@ -45,6 +46,7 @@ 'saves' => 'Guardados', 'shares' => 'Compartidos', 'subscribers' => 'Suscriptores', + 'upvotes' => 'Votos positivos', 'subscribers_gained' => 'Suscriptores Ganados', 'subscribers_lost' => 'Suscriptores Perdidos', 'total_likes' => 'Total de Me gusta', diff --git a/lang/es/posts.php b/lang/es/posts.php index c1da9111..27335f5a 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -181,6 +181,29 @@ 'embed_image' => 'URL de la imagen', 'embed_color' => 'Color', ], + 'reddit' => [ + 'settings' => 'Configuración de Reddit', + 'posting_to' => 'Publicando en', + 'searching' => 'Buscando…', + 'add_subreddit' => 'Añadir subreddit', + 'remove_subreddit' => 'Quitar', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Buscar subreddits…', + 'title' => 'Título', + 'post_type' => 'Tipo de publicación', + 'type_self' => 'Texto', + 'type_link' => 'Enlace', + 'type_image' => 'Imagen', + 'url' => 'URL del enlace', + 'flair' => 'Flair', + 'no_flair' => 'Sin flair', + 'nsfw' => 'Marcar como NSFW', + 'spoiler' => 'Marcar como spoiler', + 'subreddit_required' => 'Añade al menos un subreddit para publicar.', + 'title_required' => 'Cada subreddit necesita un título.', + 'url_required' => 'Una publicación de enlace necesita una URL.', + 'flair_required' => 'Este subreddit requiere un flair.', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -512,6 +535,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'reddit_post' => [ + 'label' => 'Publicación', + 'description' => 'Publicación de texto, enlace o imagen en un subreddit', + ], ], 'platforms' => [ @@ -617,6 +644,7 @@ 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', 'discord_message' => 'Mensaje de Discord', + 'reddit_post' => 'Publicación en Reddit', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index a4382bb9..4c34162b 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'reddit' => 'Conecte sua conta do Reddit', ], 'disconnect_modal' => [ diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index bad4c550..9c8fd496 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Seguindo', 'impressions' => 'Impressões', 'interactions' => 'Interações', + 'karma' => 'Karma', 'likes' => 'Curtidas', 'members' => 'Membros', 'minutes_watched' => 'Minutos Assistidos', @@ -45,6 +46,7 @@ 'saves' => 'Salvos', 'shares' => 'Compartilhamentos', 'subscribers' => 'Inscritos', + 'upvotes' => 'Votos positivos', 'subscribers_gained' => 'Inscritos Ganhos', 'subscribers_lost' => 'Inscritos Perdidos', 'total_likes' => 'Curtidas Totais', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 831b745e..09e6b9ec 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -181,6 +181,29 @@ 'embed_image' => 'URL da imagem', 'embed_color' => 'Cor', ], + 'reddit' => [ + 'settings' => 'Configurações do Reddit', + 'posting_to' => 'Publicando em', + 'searching' => 'Buscando…', + 'add_subreddit' => 'Adicionar subreddit', + 'remove_subreddit' => 'Remover', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Buscar subreddits…', + 'title' => 'Título', + 'post_type' => 'Tipo de post', + 'type_self' => 'Texto', + 'type_link' => 'Link', + 'type_image' => 'Imagem', + 'url' => 'URL do link', + 'flair' => 'Flair', + 'no_flair' => 'Sem flair', + 'nsfw' => 'Marcar como NSFW', + 'spoiler' => 'Marcar como spoiler', + 'subreddit_required' => 'Adicione ao menos um subreddit para publicar.', + 'title_required' => 'Cada subreddit precisa de um título.', + 'url_required' => 'Um post de link precisa de uma URL.', + 'flair_required' => 'Este subreddit exige um flair.', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -512,6 +535,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'reddit_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto, link ou imagem em um subreddit', + ], ], 'platforms' => [ @@ -616,6 +643,7 @@ 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', 'discord_message' => 'Mensagem do Discord', + 'reddit_post' => 'Post no Reddit', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', diff --git a/public/images/accounts/reddit.png b/public/images/accounts/reddit.png new file mode 100644 index 00000000..14e73ac7 Binary files /dev/null and b/public/images/accounts/reddit.png differ diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 5295d3db..b4e787f2 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -7,6 +7,7 @@ import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; +import RedditSettings from '@/components/posts/editor/RedditSettings.vue'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; @@ -177,6 +178,14 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :preview-only="previewOnly" @update:meta="emit('update:meta', channel.id, $event)" /> + diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index fdee97f1..6654fe66 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -109,6 +109,11 @@ const platformTheme: Record< rotate: 'rotate-1', image: '/images/accounts/discord.png', }, + reddit: { + bg: 'bg-orange-200', + rotate: '-rotate-1', + image: '/images/accounts/reddit.png', + }, }; const themeFor = (value: string) => diff --git a/resources/js/components/posts/editor/RedditSettings.vue b/resources/js/components/posts/editor/RedditSettings.vue new file mode 100644 index 00000000..762e5e6e --- /dev/null +++ b/resources/js/components/posts/editor/RedditSettings.vue @@ -0,0 +1,447 @@ + + + diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index fee914f6..bf42e2b6 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -10,6 +10,7 @@ import InstagramPreview from './InstagramPreview.vue'; import LinkedInPreview from './LinkedInPreview.vue'; import MastodonPreview from './MastodonPreview.vue'; import PinterestPreview from './PinterestPreview.vue'; +import RedditPreview from './RedditPreview.vue'; import TelegramPreview from './TelegramPreview.vue'; import ThreadsPreview from './ThreadsPreview.vue'; import TikTokPreview from './TikTokPreview.vue'; @@ -71,6 +72,8 @@ const previewComponent = computed(() => { return TelegramPreview; case 'discord': return DiscordPreview; + case 'reddit': + return RedditPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/RedditPreview.vue b/resources/js/components/posts/previews/RedditPreview.vue new file mode 100644 index 00000000..0833dcda --- /dev/null +++ b/resources/js/components/posts/previews/RedditPreview.vue @@ -0,0 +1,187 @@ + + + diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index e00fd0f1..018c805c 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -13,6 +13,7 @@ const PLATFORM_LOGOS: Record = { mastodon: '/images/accounts/mastodon.png', telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', + reddit: '/images/accounts/reddit.png', }; const PLATFORM_LABELS: Record = { @@ -30,6 +31,7 @@ const PLATFORM_LABELS: Record = { mastodon: 'Mastodon', telegram: 'Telegram', discord: 'Discord', + reddit: 'Reddit', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -51,6 +53,7 @@ const PLATFORM_CONTENT_TYPES: Record = { mastodon: ['mastodon_post'], telegram: ['telegram_post'], discord: ['discord_message'], + reddit: ['reddit_post'], }; export interface ContentTypeOption { diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index 417e6cfc..eaad5c13 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -21,6 +21,7 @@ export const ContentType = { MastodonPost: 'mastodon_post', TelegramPost: 'telegram_post', DiscordMessage: 'discord_message', + RedditPost: 'reddit_post', } as const; export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType]; diff --git a/resources/js/types/platform.ts b/resources/js/types/platform.ts index 82c86318..30cdbb2f 100644 --- a/resources/js/types/platform.ts +++ b/resources/js/types/platform.ts @@ -13,6 +13,7 @@ export const Platform = { Mastodon: 'mastodon', Telegram: 'telegram', Discord: 'discord', + Reddit: 'reddit', } as const; export type PlatformValue = (typeof Platform)[keyof typeof Platform]; diff --git a/routes/app.php b/routes/app.php index 875748dc..10d35314 100644 --- a/routes/app.php +++ b/routes/app.php @@ -18,6 +18,7 @@ use App\Http\Controllers\App\PostController; use App\Http\Controllers\App\PostTemplateController; use App\Http\Controllers\App\PresenceController; +use App\Http\Controllers\App\RedditController as AppRedditController; use App\Http\Controllers\App\Settings\AccountController; use App\Http\Controllers\App\Settings\AuthenticationController; use App\Http\Controllers\App\Settings\NotificationPreferenceController; @@ -38,6 +39,7 @@ use App\Http\Controllers\Auth\LinkedInPageController; use App\Http\Controllers\Auth\MastodonController; use App\Http\Controllers\Auth\PinterestController; +use App\Http\Controllers\Auth\RedditController; use App\Http\Controllers\Auth\SocialController; use App\Http\Controllers\Auth\TelegramController; use App\Http\Controllers\Auth\ThreadsController; @@ -125,6 +127,9 @@ Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect'); Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback'); + + Route::get('connect/reddit', [RedditController::class, 'connect'])->name('app.social.reddit.connect'); + Route::get('accounts/reddit/callback', [RedditController::class, 'callback'])->name('app.social.reddit.callback'); }); // Routes that require active subscription and completed onboarding @@ -138,6 +143,14 @@ ->middleware('throttle:60,1') ->name('app.discord.mentions'); + // Reddit — live lookups for the composer (subreddit typeahead + restrictions/flair). + Route::get('reddit/accounts/{account}/subreddits', [AppRedditController::class, 'subreddits']) + ->middleware('throttle:60,1') + ->name('app.reddit.subreddits'); + Route::get('reddit/accounts/{account}/subreddits/{subreddit}/restrictions', [AppRedditController::class, 'restrictions']) + ->middleware('throttle:60,1') + ->name('app.reddit.restrictions'); + // Workspaces Route::get('workspaces', [WorkspaceController::class, 'index'])->name('app.workspaces.index'); Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('app.workspaces.switch'); diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index 4865df41..b343f73f 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -156,3 +156,114 @@ expect($platform->fresh()->meta['channel_id'])->toBe('444555666'); Queue::assertPushed(PublishPost::class); }); + +it('persists Reddit subreddits meta on store', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $this->withHeaders($this->headers) + ->postJson(route('api.posts.store'), [ + 'content' => 'Hello Reddit', + 'platforms' => [[ + 'social_account_id' => $account->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My title', 'type' => 'self', 'nsfw' => false]]], + ]], + ]) + ->assertCreated(); + + $meta = PostPlatform::where('social_account_id', $account->id)->sole()->meta; + + expect(data_get($meta, 'subreddits.0.name'))->toBe('AskReddit') + ->and(data_get($meta, 'subreddits.0.title'))->toBe('My title'); +}); + +it('rejects publishing a Reddit post with no subreddit', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => []], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.meta.subreddits']); +}); + +it('rejects publishing a Reddit post where a subreddit has a blank title', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => '', 'type' => 'self']]], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [[ + 'id' => $platform->id, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => '', 'type' => 'self']]], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'platforms.0.meta.subreddits' => __('posts.form.reddit.title_required'), + ]); +}); + +it('rejects publishing a Reddit link post with no url', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Link', 'type' => 'link', 'url' => null]]], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [[ + 'id' => $platform->id, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Link', 'type' => 'link']]], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'platforms.0.meta.subreddits' => __('posts.form.reddit.url_required'), + ]); +}); + +it('rejects publishing a Reddit post where flair is required but missing', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Post', 'type' => 'self', 'flair_required' => true]]], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [[ + 'id' => $platform->id, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Post', 'type' => 'self', 'flair_required' => true]]], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'platforms.0.meta.subreddits' => __('posts.form.reddit.flair_required'), + ]); +}); diff --git a/tests/Feature/Automation/Node/HttpRequestNodeTest.php b/tests/Feature/Automation/Node/HttpRequestNodeTest.php index 4f5a8188..1d700ff9 100644 --- a/tests/Feature/Automation/Node/HttpRequestNodeTest.php +++ b/tests/Feature/Automation/Node/HttpRequestNodeTest.php @@ -277,7 +277,7 @@ 'headers' => ['User-Agent' => 'user-supplied-agent'], ]); - Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent'))); + Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('app.user_agent'))); }); it('sends custom headers configured in the editor', function () { diff --git a/tests/Feature/Automation/Node/WebhookNodeTest.php b/tests/Feature/Automation/Node/WebhookNodeTest.php index 727de55b..dab4f2b0 100644 --- a/tests/Feature/Automation/Node/WebhookNodeTest.php +++ b/tests/Feature/Automation/Node/WebhookNodeTest.php @@ -42,7 +42,7 @@ 'payload_template' => '{}', ]); - Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent'))); + Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('app.user_agent'))); }); it('escapes special characters in templated payload values so the JSON stays valid', function () { diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 92abd65a..68bf272c 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -20,6 +20,7 @@ use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; use App\Services\Social\LinkedInPublisher; +use App\Services\Social\Reddit\RedditPublisher; use Carbon\Carbon; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; @@ -626,3 +627,28 @@ expect($this->postPlatform->error_context['category'])->toBe('token_expired'); expect($this->postPlatform->error_context['platform_error_code'])->toBe('190'); }); + +test('a Reddit PostPlatform is routed to RedditPublisher', function () { + Event::fake(); + + $redditAccount = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $redditPlatform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $redditAccount->id, + 'enabled' => true, + ]); + + $publisher = Mockery::mock(RedditPublisher::class); + $publisher->shouldReceive('publish')->once()->andReturn([ + 'id' => 'reddit-123', + 'url' => 'https://www.reddit.com/r/test/comments/reddit-123/', + ]); + + $this->app->instance(RedditPublisher::class, $publisher); + + (new PublishToSocialPlatform($redditPlatform))->handle(); + + $redditPlatform->refresh(); + expect($redditPlatform->status)->toBe(PlatformStatus::Published) + ->and($redditPlatform->platform_post_id)->toBe('reddit-123'); +}); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 43f6ee26..a8c2dda8 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -178,3 +178,110 @@ $response->assertOk(); Queue::assertPushed(PublishPost::class); }); + +test('create post persists Reddit subreddits meta', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Hello Reddit', + 'platforms' => [[ + 'social_account_id' => $account->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => [ + 'subreddits' => [['name' => 'AskReddit', 'title' => 'My title', 'type' => 'self', 'nsfw' => false]], + ], + ]], + ]); + + $response->assertOk(); + + $meta = PostPlatform::where('social_account_id', $account->id)->sole()->meta; + + expect(data_get($meta, 'subreddits.0.name'))->toBe('AskReddit') + ->and(data_get($meta, 'subreddits.0.title'))->toBe('My title'); +}); + +test('publish post rejects a Reddit platform without a subreddit', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => []], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.reddit.subreddit_required')]); +}); + +test('publish post rejects a Reddit platform where a subreddit has a blank title', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => '', 'type' => 'self']]], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.reddit.title_required')]); +}); + +test('publish post rejects a Reddit link platform with no url', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Link', 'type' => 'link', 'url' => null]]], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.reddit.url_required')]); +}); + +test('publish post rejects a Reddit platform where flair is required but missing', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My Post', 'type' => 'self', 'flair_required' => true]]], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.reddit.flair_required')]); +}); diff --git a/tests/Feature/Reddit/RedditLookupTest.php b/tests/Feature/Reddit/RedditLookupTest.php new file mode 100644 index 00000000..c93dc28f --- /dev/null +++ b/tests/Feature/Reddit/RedditLookupTest.php @@ -0,0 +1,67 @@ + 'https://oauth.reddit.com', + 'app.user_agent' => 'web:it.trypost:1.0', + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id, 'user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->user->refresh(); + + $this->account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); +}); + +test('searches subreddits for a connected account', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/subreddits/search*' => Http::response([ + 'data' => ['children' => [['data' => ['display_name' => 'AskReddit', 'subreddit_type' => 'public', 'title' => 'Ask Reddit', 'subscribers' => 1, 'over18' => false]]]], + ], 200), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $this->account->id, 'q' => 'ask'])) + ->assertOk() + ->assertJsonPath('data.0.name', 'AskReddit'); +}); + +test('returns empty array when query is blank', function () { + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $this->account->id, 'q' => ''])) + ->assertOk() + ->assertJsonPath('data', []); +}); + +test('returns restrictions for a subreddit', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/pics/about*' => Http::response(['data' => ['submission_type' => 'any', 'allow_images' => true]], 200), + config('trypost.platforms.reddit.api').'/api/v1/pics/post_requirements*' => Http::response(['is_flair_required' => false], 200), + config('trypost.platforms.reddit.api').'/r/pics/api/link_flair_v2*' => Http::response([], 200), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.restrictions', ['account' => $this->account->id, 'subreddit' => 'pics'])) + ->assertOk() + ->assertJsonPath('data.allowed_types.0', 'self'); +}); + +test('forbids looking up a reddit account in another workspace', function () { + $other = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => Workspace::factory()->create()->id, + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $other->id, 'q' => 'x'])) + ->assertForbidden(); +}); diff --git a/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php b/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php new file mode 100644 index 00000000..9a6a92a0 --- /dev/null +++ b/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php @@ -0,0 +1,102 @@ + Http::response(['name' => 'testuser'], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + $result = (new ConnectionVerifier)->verify($account); + + expect($result)->toBeTrue(); + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/me')); +}); + +test('throws TokenExpiredException when reddit verify returns 401', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/v1/me' => Http::response([], 401), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->addDays(30), + 'refresh_token' => null, + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class, 'Reddit access token is invalid or expired'); +}); + +test('throws TokenExpiredException when reddit verify returns 403', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/v1/me' => Http::response([], 403), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->addDays(30), + 'refresh_token' => null, + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class, 'Reddit access token is invalid or expired'); +}); + +test('refreshing a reddit token stores the new access token', function () { + Http::fake([ + config('trypost.platforms.reddit.oauth_api').'/access_token' => Http::response([ + 'access_token' => 'new-access', + 'expires_in' => 3600, + ], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'access_token' => 'old-access', + 'refresh_token' => 'reddit-refresh-token', + ]); + + (new ConnectionVerifier)->refreshToken($account); + + expect($account->fresh()->access_token)->toBe('new-access'); +}); + +test('refreshes reddit token before verifying when expired', function () { + Http::fake([ + config('trypost.platforms.reddit.oauth_api').'/access_token' => Http::response([ + 'access_token' => 'new-access', + 'expires_in' => 3600, + ], 200), + config('trypost.platforms.reddit.api').'/api/v1/me' => Http::response(['name' => 'testuser'], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->subHour(), + 'access_token' => 'old-access', + 'refresh_token' => 'reddit-refresh-token', + ]); + + $result = (new ConnectionVerifier)->verify($account); + + expect($result)->toBeTrue(); + expect($account->fresh()->access_token)->toBe('new-access'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/access_token')); +}); + +test('throws TokenExpiredException when reddit has no refresh token', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->subHour(), + 'refresh_token' => null, + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(TokenExpiredException::class, 'No refresh token available for Reddit account'); +}); diff --git a/tests/Feature/Services/Social/RedditAnalyticsTest.php b/tests/Feature/Services/Social/RedditAnalyticsTest.php new file mode 100644 index 00000000..177cd89c --- /dev/null +++ b/tests/Feature/Services/Social/RedditAnalyticsTest.php @@ -0,0 +1,74 @@ + 'https://oauth.reddit.com', + 'app.user_agent' => 'web:it.trypost:1.0', + ]); + $this->account = SocialAccount::factory()->reddit()->create(['workspace_id' => Workspace::factory()->create()->id]); +}); + +test('post metrics sum score and comments across subreddits', function () { + Http::fake([config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => [ + ['data' => ['name' => 't3_one', 'score' => 10, 'num_comments' => 3]], + ['data' => ['name' => 't3_two', 'score' => 5, 'num_comments' => 2]], + ]]], 200)]); + + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => 't3_one,t3_two', + ]); + + $metrics = app(RedditAnalytics::class)->fetchPostMetrics($platform); + + expect($metrics)->not->toBeEmpty() + ->and((int) collect($metrics)->firstWhere('kind', 'reaction')['value'])->toBe(15) + ->and((int) collect($metrics)->firstWhere('kind', 'comments')['value'])->toBe(5); +}); + +test('account metrics expose total karma', function () { + Http::fake([config('trypost.platforms.reddit.api').'/api/v1/me*' => Http::response(['total_karma' => 1234, 'link_karma' => 1000, 'comment_karma' => 234], 200)]); + + $metrics = app(RedditAnalytics::class)->getMetrics($this->account); + + expect((int) $metrics[0]['value'])->toBe(1234); +}); + +test('post metrics return empty when platform_post_id is blank', function () { + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => null, + ]); + + expect(app(RedditAnalytics::class)->fetchPostMetrics($platform))->toBe([]); +}); + +test('post metrics return empty when info API returns no children', function () { + Http::fake([config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200)]); + + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => 't3_abc', + ]); + + expect(app(RedditAnalytics::class)->fetchPostMetrics($platform))->toBe([]); +}); + +test('account metrics return empty when me API throws a connection exception', function () { + Http::fake([config('trypost.platforms.reddit.api').'/api/v1/me*' => fn () => throw new ConnectionException('timeout')]); + + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->account->workspace_id]); + + $metrics = app(RedditAnalytics::class)->getMetrics($account); + + expect($metrics)->toBe([]); +}); diff --git a/tests/Feature/Services/Social/RedditClientTest.php b/tests/Feature/Services/Social/RedditClientTest.php new file mode 100644 index 00000000..ea22acd5 --- /dev/null +++ b/tests/Feature/Services/Social/RedditClientTest.php @@ -0,0 +1,131 @@ + 'https://oauth.reddit.com', + 'app.user_agent' => 'web:it.trypost:1.0', + ]); + $this->account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => Workspace::factory()->create()->id, + 'access_token' => 'tok', + ]); +}); + +test('searchSubreddits returns names from the reddit search endpoint', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/subreddits/search*' => Http::response([ + 'data' => ['children' => [ + ['data' => ['display_name' => 'AskReddit', 'title' => 'Ask Reddit', 'subscribers' => 100, 'over18' => false, 'subreddit_type' => 'public']], + ['data' => ['display_name' => 'pics', 'title' => 'Pics', 'subscribers' => 50, 'over18' => false, 'subreddit_type' => 'public']], + ]], + ], 200), + ]); + + $results = app(RedditClient::class)->searchSubreddits($this->account, 'ask'); + + expect($results)->toHaveCount(2) + ->and($results[0]['name'])->toBe('AskReddit'); + + Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('app.user_agent'))); +}); + +test('restrictions reports submission type, image allowance and required flair', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/AskReddit/about*' => Http::response([ + 'data' => ['submission_type' => 'self', 'allow_images' => false], + ], 200), + config('trypost.platforms.reddit.api').'/api/v1/AskReddit/post_requirements*' => Http::response([ + 'is_flair_required' => true, + ], 200), + config('trypost.platforms.reddit.api').'/r/AskReddit/api/link_flair_v2*' => Http::response([ + ['id' => 'abc', 'text' => 'Discussion'], + ], 200), + ]); + + $restrictions = app(RedditClient::class)->restrictions($this->account, 'AskReddit'); + + expect($restrictions['allowed_types'])->toBe(['self']) + ->and($restrictions['flair_required'])->toBeTrue() + ->and($restrictions['flairs'][0]['id'])->toBe('abc'); +}); + +test('restrictions adds image (not video or gallery) when images are allowed', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/pics/about*' => Http::response(['data' => ['submission_type' => 'any', 'allow_images' => true]], 200), + config('trypost.platforms.reddit.api').'/api/v1/pics/post_requirements*' => Http::response(['is_flair_required' => false], 200), + config('trypost.platforms.reddit.api').'/r/pics/api/link_flair_v2*' => Http::response([], 200), + ]); + + $r = app(RedditClient::class)->restrictions($this->account, 'pics'); + + expect($r['allowed_types'])->toContain('self')->toContain('link')->toContain('image') + ->not->toContain('video')->not->toContain('gallery'); +}); + +test('restrictions returns only link type when submission_type is link and images not allowed', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/AnnounceOnly/about*' => Http::response([ + 'data' => ['submission_type' => 'link', 'allow_images' => false], + ], 200), + config('trypost.platforms.reddit.api').'/api/v1/AnnounceOnly/post_requirements*' => Http::response([ + 'is_flair_required' => false, + ], 200), + config('trypost.platforms.reddit.api').'/r/AnnounceOnly/api/link_flair_v2*' => Http::response([], 200), + ]); + + $r = app(RedditClient::class)->restrictions($this->account, 'AnnounceOnly'); + + expect($r['allowed_types'])->toBe(['link']) + ->not->toContain('self') + ->not->toContain('image'); +}); + +test('flairs returns empty array when the flair endpoint throws a connection exception', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/AskReddit/api/link_flair_v2*' => fn () => throw new ConnectionException('Connection refused'), + ]); + + $flairs = app(RedditClient::class)->flairs($this->account, 'AskReddit'); + + expect($flairs)->toBe([]); +}); + +test('restrictions returns empty flairs array when the flair endpoint throws a connection exception', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/AskReddit/about*' => Http::response([ + 'data' => ['submission_type' => 'any', 'allow_images' => false], + ], 200), + config('trypost.platforms.reddit.api').'/api/v1/AskReddit/post_requirements*' => Http::response([ + 'is_flair_required' => true, + ], 200), + config('trypost.platforms.reddit.api').'/r/AskReddit/api/link_flair_v2*' => fn () => throw new ConnectionException('Connection refused'), + ]); + + $r = app(RedditClient::class)->restrictions($this->account, 'AskReddit'); + + expect($r['flairs'])->toBe([]); +}); + +test('info sums nothing for empty fullnames and maps children otherwise', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/info*' => Http::response([ + 'data' => ['children' => [ + ['data' => ['name' => 't3_abc', 'score' => 12, 'num_comments' => 4, 'url' => 'https://www.reddit.com/r/x/comments/abc/y/']], + ]], + ], 200), + ]); + + expect(app(RedditClient::class)->info($this->account, []))->toBe([]); + + $info = app(RedditClient::class)->info($this->account, ['t3_abc']); + expect($info['t3_abc']['score'])->toBe(12) + ->and($info['t3_abc']['num_comments'])->toBe(4); +}); diff --git a/tests/Feature/Services/Social/RedditPublisherTest.php b/tests/Feature/Services/Social/RedditPublisherTest.php new file mode 100644 index 00000000..42c5b537 --- /dev/null +++ b/tests/Feature/Services/Social/RedditPublisherTest.php @@ -0,0 +1,296 @@ + 'https://oauth.reddit.com', + 'app.user_agent' => 'web:it.trypost:1.0', + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'tok', + ]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Hello Reddit', + ]); +}); + +function redditPlatform(array $subreddits): PostPlatform +{ + return PostPlatform::factory()->create([ + 'post_id' => test()->post->id, + 'social_account_id' => test()->account->id, + 'platform' => Platform::Reddit, + 'content_type' => ContentType::RedditPost, + 'enabled' => true, + 'meta' => ['subreddits' => $subreddits], + ]); +} + +test('publishes a self post and resolves the url via info', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_abc', 'id' => 'abc']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => [ + ['data' => ['name' => 't3_abc', 'url' => 'https://www.reddit.com/r/test/comments/abc/x/']], + ]]], 200), + ]); + + $platform = redditPlatform([['name' => 'test', 'title' => 'My title', 'type' => 'self']]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('abc')->and($result['url'])->toContain('reddit.com'); + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && $r['kind'] === 'self' && $r['sr'] === 'test' && $r['title'] === 'My title'); +}); + +test('submits a link post with the url', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_l', 'id' => 'l']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => 'link', 'url' => 'https://example.com']]); + app(RedditPublisher::class)->publish($platform); + + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && $r['kind'] === 'link' && $r['url'] === 'https://example.com'); +}); + +test('submits to multiple subreddits and aggregates ids', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/submit*' => Http::sequence() + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_one', 'id' => 'one']]]) + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_two', 'id' => 'two']]]), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([ + ['name' => 'a', 'title' => 'T', 'type' => 'self'], + ['name' => 'b', 'title' => 'T', 'type' => 'self'], + ]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('one')->toContain('two'); +}); + +test('records partial failure when a later subreddit fails', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/submit*' => Http::sequence() + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_one', 'id' => 'one']]]) + ->push(['json' => ['errors' => [['SUBREDDIT_NOEXIST', 'that subreddit does not exist', 'sr']]]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([ + ['name' => 'ok', 'title' => 'T', 'type' => 'self'], + ['name' => 'bad', 'title' => 'T', 'type' => 'self'], + ]); + + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); + expect(data_get($platform->fresh()->meta, 'results.0.id'))->toContain('one'); +}); + +test('throws when no subreddit is configured', function () { + $platform = redditPlatform([]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); + +test('sends nsfw, spoiler and flair fields in the payload', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_abc', 'id' => 'abc']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([[ + 'name' => 'test', + 'title' => 'T', + 'type' => 'self', + 'nsfw' => true, + 'spoiler' => true, + 'flair_id' => 'flair-123', + 'flair_text' => 'Discussion', + ]]); + app(RedditPublisher::class)->publish($platform); + + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') + && $r['nsfw'] === 'true' + && $r['spoiler'] === 'true' + && $r['flair_id'] === 'flair-123' + && $r['flair_text'] === 'Discussion'); +}); + +test('throws when a subreddit has no title', function () { + $platform = redditPlatform([['name' => 'test', 'type' => 'self']]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); + +test('throws for video type since it is not yet supported', function () { + $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => 'video']]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); + +test('throws when an image post has no media attached', function () { + $platform = redditPlatform([['name' => 'pics', 'title' => 'My photo', 'type' => 'image']]); + + expect(fn () => app(RedditPublisher::class)->publish($platform)) + ->toThrow(RedditPublishException::class); +}); + +test('throws when s3 upload returns a non-2xx status', function () { + test()->post->update([ + 'media' => [[ + 'id' => 'm2', + 'path' => 'media/2026-01/photo.jpg', + 'url' => 'https://cdn.test/photo.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'photo.jpg', + ]], + ]); + + Http::fake([ + config('trypost.platforms.reddit.api').'/api/media/asset*' => Http::response([ + 'args' => [ + 'action' => '//reddit-uploads.s3.amazonaws.com', + 'fields' => [['name' => 'key', 'value' => 'abc/photo.jpg']], + ], + 'asset' => ['asset_id' => 'a1'], + ], 200), + 'https://cdn.test/photo.jpg' => Http::response('binarybytes', 200), + 'https://reddit-uploads.s3.amazonaws.com' => Http::response('Forbidden', 403), + ]); + + $platform = redditPlatform([['name' => 'pics', 'title' => 'My photo', 'type' => 'image']]); + + expect(fn () => app(RedditPublisher::class)->publish($platform)) + ->toThrow(RedditPublishException::class, 'Failed to upload the image to Reddit.'); +}); + +test('uploads an image then submits the asset url', function () { + test()->post->update([ + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/2026-01/photo.jpg', + 'url' => 'https://cdn.test/photo.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'photo.jpg', + ]], + ]); + + Http::fake([ + config('trypost.platforms.reddit.api').'/api/media/asset*' => Http::response([ + 'args' => [ + 'action' => '//reddit-uploads.s3.amazonaws.com', + 'fields' => [['name' => 'key', 'value' => 'abc/photo.jpg'], ['name' => 'policy', 'value' => 'p']], + ], + 'asset' => ['asset_id' => 'a1'], + ], 200), + 'https://reddit-uploads.s3.amazonaws.com' => Http::response('https://reddit-uploads.s3.amazonaws.com/abc/photo.jpg', 201), + 'https://cdn.test/photo.jpg' => Http::response('binarybytes', 200), + config('trypost.platforms.reddit.api').'/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_img', 'id' => 'img']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([['name' => 'pics', 'title' => 'My photo', 'type' => 'image']]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('img'); + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && isset($r['url']) && str_contains((string) $r['url'], 'photo.jpg')); +}); + +test('gallery: 3 images upload and submit via submit_gallery_post with 3 media_ids', function () { + test()->post->update([ + 'media' => [ + ['id' => 'g1', 'path' => 'media/img1.jpg', 'url' => 'https://cdn.test/img1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img1.jpg'], + ['id' => 'g2', 'path' => 'media/img2.jpg', 'url' => 'https://cdn.test/img2.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img2.jpg'], + ['id' => 'g3', 'path' => 'media/img3.jpg', 'url' => 'https://cdn.test/img3.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img3.jpg'], + ], + ]); + + Http::fake([ + config('trypost.platforms.reddit.api').'/api/media/asset*' => Http::sequence() + ->push(['args' => ['action' => '//reddit-uploads.s3.amazonaws.com', 'fields' => [['name' => 'key', 'value' => 'abc/img1.jpg']]], 'asset' => ['asset_id' => 'asset-id-1']], 200) + ->push(['args' => ['action' => '//reddit-uploads.s3.amazonaws.com', 'fields' => [['name' => 'key', 'value' => 'abc/img2.jpg']]], 'asset' => ['asset_id' => 'asset-id-2']], 200) + ->push(['args' => ['action' => '//reddit-uploads.s3.amazonaws.com', 'fields' => [['name' => 'key', 'value' => 'abc/img3.jpg']]], 'asset' => ['asset_id' => 'asset-id-3']], 200), + 'https://reddit-uploads.s3.amazonaws.com' => Http::response('https://reddit-uploads.s3.amazonaws.com/abc/img.jpg', 201), + 'https://cdn.test/img1.jpg' => Http::response('bytes1', 200), + 'https://cdn.test/img2.jpg' => Http::response('bytes2', 200), + 'https://cdn.test/img3.jpg' => Http::response('bytes3', 200), + config('trypost.platforms.reddit.api').'/api/submit_gallery_post.json*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_gal', 'id' => 'gal']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => [ + ['data' => ['name' => 't3_gal', 'url' => 'https://www.reddit.com/r/pics/comments/gal/x/']], + ]]], 200), + ]); + + $platform = redditPlatform([['name' => 'pics', 'title' => 'My gallery', 'type' => 'image']]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('gal'); + Http::assertSent(function ($r) { + if (! str_contains($r->url(), '/api/submit_gallery_post.json')) { + return false; + } + $items = json_decode((string) $r['items'], true); + + return is_array($items) + && count($items) === 3 + && $items[0]['media_id'] === 'asset-id-1' + && $items[1]['media_id'] === 'asset-id-2' + && $items[2]['media_id'] === 'asset-id-3'; + }); +}); + +test('more than 10 images are capped at 10', function () { + $media = collect(range(1, 12))->map(fn ($i) => [ + 'id' => "g{$i}", + 'path' => "media/img{$i}.jpg", + 'url' => "https://cdn.test/img{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'original_filename' => "img{$i}.jpg", + ])->all(); + + test()->post->update(['media' => $media]); + + $leaseSequence = Http::sequence(); + for ($i = 1; $i <= 10; $i++) { + $leaseSequence->push(['args' => ['action' => '//reddit-uploads.s3.amazonaws.com', 'fields' => [['name' => 'key', 'value' => "abc/img{$i}.jpg"]]], 'asset' => ['asset_id' => "asset-id-{$i}"]], 200); + } + + $cdnFakes = []; + for ($i = 1; $i <= 12; $i++) { + $cdnFakes["https://cdn.test/img{$i}.jpg"] = Http::response("bytes{$i}", 200); + } + + Http::fake(array_merge([ + config('trypost.platforms.reddit.api').'/api/media/asset*' => $leaseSequence, + 'https://reddit-uploads.s3.amazonaws.com' => Http::response('https://reddit-uploads.s3.amazonaws.com/abc/img.jpg', 201), + config('trypost.platforms.reddit.api').'/api/submit_gallery_post.json*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_cap', 'id' => 'cap']]], 200), + config('trypost.platforms.reddit.api').'/api/info*' => Http::response(['data' => ['children' => []]], 200), + ], $cdnFakes)); + + $platform = redditPlatform([['name' => 'pics', 'title' => 'Gallery cap test', 'type' => 'image']]); + app(RedditPublisher::class)->publish($platform); + + Http::assertSent(function ($r) { + if (! str_contains($r->url(), '/api/submit_gallery_post.json')) { + return false; + } + $items = json_decode((string) $r['items'], true); + + return is_array($items) && count($items) === 10; + }); +}); diff --git a/tests/Feature/Social/RedditConnectTest.php b/tests/Feature/Social/RedditConnectTest.php new file mode 100644 index 00000000..1b1ff828 --- /dev/null +++ b/tests/Feature/Social/RedditConnectTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); +}); + +test('reddit connect redirects to the oauth provider', function () { + $driverMock = Mockery::mock(); + $driverMock->shouldReceive('scopes')->andReturnSelf(); + $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ + 'getTargetUrl' => 'https://www.reddit.com/api/v1/authorize?test=1', + ])); + + Socialite::shouldReceive('driver')->with('reddit')->andReturn($driverMock); + + $this->actingAs($this->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.reddit.connect')) + ->assertStatus(409); + + expect(session('social_connect_workspace'))->toBe($this->workspace->id); +}); + +test('reddit oauth callback creates the account', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('t2_abc123'); + $socialiteUser->shouldReceive('getNickname')->andReturn('redditor_name'); + $socialiteUser->shouldReceive('getName')->andReturn('Redditor Name'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'reddit-access-token'; + $socialiteUser->refreshToken = 'reddit-refresh-token'; + $socialiteUser->expiresIn = 3600; + $socialiteUser->approvedScopes = ['submit']; + + Socialite::shouldReceive('driver')->with('reddit')->andReturn(Mockery::mock(['user' => $socialiteUser])); + + $response = $this->actingAs($this->user)->get(route('app.social.reddit.callback')); + + $response->assertOk(); + $response->assertViewHas('success', true); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Reddit->value, + 'platform_user_id' => 't2_abc123', + 'status' => Status::Connected->value, + ]); +}); + +test('reddit callback fails gracefully on socialite error', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $mock = Mockery::mock(); + $mock->shouldReceive('user')->andThrow(new RuntimeException('Reddit OAuth failed.')); + + Socialite::shouldReceive('driver')->with('reddit')->andReturn($mock); + + $response = $this->actingAs($this->user)->get(route('app.social.reddit.callback')); + + $response->assertOk(); + $response->assertViewHas('success', false); + + expect($this->workspace->socialAccounts()->where('platform', Platform::Reddit)->count())->toBe(0); +}); diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index d28bbf70..fbbfb1f5 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -186,3 +186,23 @@ Event::assertDispatched(NotificationCreated::class); Mail::assertQueued(AccountDisconnected::class); }); + +// ---- profileUrl ---- + +test('reddit account profileUrl returns correct reddit user url', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'redditor_one', + ]); + + expect($account->profileUrl)->toBe('https://www.reddit.com/user/redditor_one'); +}); + +test('reddit account profileUrl returns null when username is blank', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => '', + ]); + + expect($account->profileUrl)->toBeNull(); +}); diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index 0a3ce111..99c300b1 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -585,3 +585,64 @@ ]) ->assertSessionDoesntHaveErrors('platforms.0.meta.channel_id'); }); + +test('publishing a reddit post with empty subreddits is rejected', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $postPlatform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'meta' => ['subreddits' => []], + ]); + + $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Publishing->value, + 'platforms' => [[ + 'id' => $postPlatform->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => ['subreddits' => []], + ]], + ]) + ->assertSessionHasErrors('platforms.0.meta.subreddits'); +}); + +test('scheduling a reddit post with empty subreddits is rejected', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $postPlatform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'meta' => ['subreddits' => []], + ]); + + $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Scheduled->value, + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'platforms' => [[ + 'id' => $postPlatform->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => ['subreddits' => []], + ]], + ]) + ->assertSessionHasErrors('platforms.0.meta.subreddits'); +}); + +test('saving a reddit post as draft without subreddits is allowed', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $postPlatform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'meta' => ['subreddits' => []], + ]); + + $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Draft->value, + 'platforms' => [[ + 'id' => $postPlatform->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => ['subreddits' => []], + ]], + ]) + ->assertSessionDoesntHaveErrors('platforms.0.meta.subreddits'); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 7a771504..2d593e4b 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -73,6 +73,7 @@ expect(ContentType::YouTubeShort->supportsVideo())->toBeTrue(); expect(ContentType::LinkedInCarousel->supportsVideo())->toBeFalse(); expect(ContentType::PinterestPin->supportsVideo())->toBeFalse(); + expect(ContentType::RedditPost->supportsVideo())->toBeFalse(); }); test('content type supports image correctly', function () { @@ -97,6 +98,7 @@ expect(ContentType::ThreadsPost->requiresMedia())->toBeFalse(); expect(ContentType::BlueskyPost->requiresMedia())->toBeFalse(); expect(ContentType::MastodonPost->requiresMedia())->toBeFalse(); + expect(ContentType::RedditPost->requiresMedia())->toBeFalse(); }); test('can get content types for platform', function () { @@ -142,3 +144,23 @@ expect(ContentType::BlueskyPost->supportsVideo())->toBeTrue(); expect(ContentType::MastodonPost->supportsVideo())->toBeTrue(); }); + +test('reddit post content type maps to reddit platform', function () { + expect(ContentType::RedditPost->value)->toBe('reddit_post') + ->and(ContentType::RedditPost->platform())->toBe(Platform::Reddit); +}); + +test('reddit post supports image but not video', function () { + expect(ContentType::RedditPost->supportsImage())->toBeTrue() + ->and(ContentType::RedditPost->supportsVideo())->toBeFalse(); +}); + +test('default content type for Reddit is RedditPost', function () { + expect(ContentType::defaultFor(Platform::Reddit))->toBe(ContentType::RedditPost); +}); + +test('forPlatform Reddit contains RedditPost', function () { + $types = ContentType::forPlatform(Platform::Reddit); + + expect($types)->toContain(ContentType::RedditPost); +}); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index d28c3ed4..d322f459 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -108,3 +108,17 @@ expect($enabled)->not->toContain(Platform::LinkedIn); }); + +test('reddit platform exposes its metadata', function () { + expect(Platform::Reddit->value)->toBe('reddit') + ->and(Platform::Reddit->label())->toBe('Reddit') + ->and(Platform::Reddit->color())->toBe('#FF4500') + ->and(Platform::Reddit->allowedMediaTypes())->toBe([MediaType::Image]) + ->and(Platform::Reddit->maxImages())->toBe(10) + ->and(Platform::Reddit->maxContentLength())->toBe(40000) + ->and(Platform::Reddit->recommendedAiContentLength())->toBe(500) + ->and(Platform::Reddit->requiredPublishScopes())->toBe(['submit']) + ->and(Platform::Reddit->supportsTextOnly())->toBeTrue() + ->and(Platform::Reddit->requiresContent())->toBeFalse() + ->and(Platform::Reddit->queue())->toBe('social-reddit'); +}); diff --git a/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php b/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php new file mode 100644 index 00000000..725e7b62 --- /dev/null +++ b/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php @@ -0,0 +1,128 @@ + 'Unauthorized'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Reddit rejected the request. Check that the account is connected and has permission to post.') + ->and($exception->platformErrorCode)->toBe('401'); +}); + +test('HTTP 403 maps to Permission category', function () { + $response = Http::response(['message' => 'Forbidden'], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('403'); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response([], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Reddit rate limit reached. Please try again shortly.') + ->and($exception->platformErrorCode)->toBe('429'); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response([], 500); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Reddit is temporarily unavailable. Please try again later.') + ->and($exception->platformErrorCode)->toBe('500'); +}); + +test('json errors array uses first error human message', function () { + $response = Http::response([ + 'jquery' => [], + 'errors' => [['SUBREDDIT_NOEXIST', 'that subreddit does not exist', 'sr']], + ], 200); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('that subreddit does not exist'); +}); + +test('empty errors array falls back to generic message', function () { + $response = Http::response(['jquery' => [], 'errors' => []], 200); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('An unknown Reddit error occurred (HTTP 200).'); +}); + +test('previous throwable is forwarded', function () { + $previous = new RuntimeException('original cause'); + $exception = new RedditPublishException( + userMessage: 'something failed', + category: ErrorCategory::Unknown, + previous: $previous, + ); + + expect($exception->getPrevious())->toBe($previous) + ->and($exception->getMessage())->toBe('something failed'); +}); + +test('platform returns reddit', function () { + $response = Http::response(['errors' => []], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('reddit'); +}); + +test('json.errors submit wrapper message takes priority over top-level errors', function () { + $response = Http::response([ + 'json' => ['errors' => [['SUBREDDIT_BANNED', 'that subreddit is banned', 'sr']]], + 'errors' => [['OTHER', 'fallback message', 'x']], + ], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->userMessage)->toBe('that subreddit is banned'); +}); + +test('explanation field is used for api errors without errors array', function () { + $response = Http::response([ + 'reason' => 'BAD_SR_NAME', + 'explanation' => 'that name is taken', + 'message' => 'Bad Request', + ], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->userMessage)->toBe('that name is taken'); +}); + +test('message field is used when no errors or explanation present', function () { + $response = Http::response(['message' => 'Forbidden'], 404); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->userMessage)->toBe('Forbidden'); +}); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index 81fd06a0..f12e0faf 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -97,3 +97,16 @@ function runMediaRule(string $contentType, array $media): array test('does nothing for invalid content type values', function () { expect(runMediaRule('not_a_real_content_type', []))->toBe([]); }); + +test('reddit text post with no media passes the rule', function () { + expect(runMediaRule(ContentType::RedditPost->value, []))->toBe([]); +}); + +test('reddit post with a video is rejected', function () { + $media = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']]; + + $errors = runMediaRule(ContentType::RedditPost->value, $media); + + expect($errors)->toHaveCount(1) + ->and($errors[0])->toContain('does not support videos'); +}); diff --git a/tests/Unit/Socialite/RedditProviderTest.php b/tests/Unit/Socialite/RedditProviderTest.php new file mode 100644 index 00000000..49c30727 --- /dev/null +++ b/tests/Unit/Socialite/RedditProviderTest.php @@ -0,0 +1,69 @@ + 'https://www.reddit.com/api/v1', + 'services.reddit.client_id' => 'cid', + ]); + + $request = Request::create('/connect/reddit', 'GET'); + $request->setLaravelSession(app('session.store')); + + $provider = new RedditProvider($request, 'cid', 'secret', 'https://trypost.test/accounts/reddit/callback'); + $url = $provider->scopes(['identity', 'submit'])->redirect()->getTargetUrl(); + + expect($url)->toContain('https://www.reddit.com/api/v1/authorize') + ->toContain('duration=permanent') + ->toContain('client_id=cid') + ->toContain('scope=identity'); +}); + +function makeProvider(): RedditProvider +{ + config([ + 'trypost.platforms.reddit.oauth_api' => 'https://www.reddit.com/api/v1', + 'services.reddit.client_id' => 'cid', + ]); + + $request = Request::create('/connect/reddit', 'GET'); + $request->setLaravelSession(app('session.store')); + + return new RedditProvider($request, 'cid', 'secret', 'https://trypost.test/accounts/reddit/callback'); +} + +function callMapUserToObject(RedditProvider $provider, array $data): SocialiteUser +{ + $ref = new ReflectionMethod($provider, 'mapUserToObject'); + + return $ref->invoke($provider, $data); +} + +test('mapUserToObject strips the query string from icon_img', function () { + $user = callMapUserToObject(makeProvider(), [ + 'id' => 'abc', + 'name' => 'spez', + 'icon_img' => 'https://i.redd.it/x.png?width=256&s=sig', + ]); + + expect($user)->toBeInstanceOf(SocialiteUser::class) + ->and($user->getId())->toBe('abc') + ->and($user->getNickname())->toBe('spez') + ->and($user->getName())->toBe('spez') + ->and($user->getAvatar())->toBe('https://i.redd.it/x.png'); +}); + +test('mapUserToObject returns null avatar when icon_img is blank', function () { + $user = callMapUserToObject(makeProvider(), [ + 'id' => 'xyz', + 'name' => 'noavatar', + 'icon_img' => '', + ]); + + expect($user->getAvatar())->toBeNull(); +});