From b133def9109fa1bfdc02616d59ed6cd64d96a5ae Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 9 Apr 2026 17:58:23 +0100 Subject: [PATCH 1/4] Separate plugin creation from submission with draft status Introduce a draft state for plugins so authors can save work-in-progress before submitting for review. Add display_name support across marketplace, cart, and checkout pages. Allow plugin owners to preview their listing. Hide de-listed plugins from the public directory. Co-Authored-By: Claude Opus 4.6 --- app/Enums/PluginActivityType.php | 8 + app/Enums/PluginStatus.php | 3 + app/Filament/Resources/PluginResource.php | 1 + .../PluginResource/Pages/ListPlugins.php | 8 + app/Http/Controllers/CartController.php | 1 + .../Controllers/PluginDirectoryController.php | 11 +- app/Livewire/Customer/Plugins/Create.php | 56 +- app/Livewire/Customer/Plugins/Index.php | 3 +- app/Livewire/Customer/Plugins/Show.php | 204 +++++- app/Livewire/PluginDirectory.php | 1 + app/Models/Plugin.php | 83 ++- database/factories/PluginFactory.php | 11 + ...change_plugins_default_status_to_draft.php | 34 + ...vert_existing_pending_plugins_to_draft.php | 27 + ...1159_add_display_name_to_plugins_table.php | 28 + package-lock.json | 2 +- resources/views/cart/show.blade.php | 5 +- resources/views/cart/success.blade.php | 11 +- .../customer/status-badge.blade.php | 2 +- .../views/components/plugin-card.blade.php | 5 +- resources/views/customer/team/show.blade.php | 3 +- .../views/customer/ultra/index.blade.php | 3 +- .../customer/developer/dashboard.blade.php | 3 +- .../customer/plugins/create.blade.php | 57 +- .../livewire/customer/plugins/index.blade.php | 36 +- .../livewire/customer/plugins/show.blade.php | 648 ++++++++++++++---- resources/views/plugin-show.blade.php | 43 +- routes/web.php | 2 +- .../CustomerPluginReviewChecksTest.php | 102 ++- tests/Feature/DeveloperTermsTest.php | 2 +- .../Livewire/Customer/PluginCreateTest.php | 47 +- .../Customer/PluginStatusTransitionsTest.php | 442 ++++++++++++ tests/Feature/PluginShowMobileVersionTest.php | 50 ++ tests/Feature/PluginSubmissionNotesTest.php | 192 ++---- 34 files changed, 1641 insertions(+), 493 deletions(-) create mode 100644 database/migrations/2026_04_08_180326_change_plugins_default_status_to_draft.php create mode 100644 database/migrations/2026_04_09_091811_convert_existing_pending_plugins_to_draft.php create mode 100644 database/migrations/2026_04_09_101159_add_display_name_to_plugins_table.php create mode 100644 tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php diff --git a/app/Enums/PluginActivityType.php b/app/Enums/PluginActivityType.php index 56a26126..497cc15c 100644 --- a/app/Enums/PluginActivityType.php +++ b/app/Enums/PluginActivityType.php @@ -9,6 +9,8 @@ enum PluginActivityType: string case Approved = 'approved'; case Rejected = 'rejected'; case DescriptionUpdated = 'description_updated'; + case Withdrawn = 'withdrawn'; + case ReturnedToDraft = 'returned_to_draft'; public function label(): string { @@ -18,6 +20,8 @@ public function label(): string self::Approved => 'Approved', self::Rejected => 'Rejected', self::DescriptionUpdated => 'Description Updated', + self::Withdrawn => 'Withdrawn', + self::ReturnedToDraft => 'Returned to Draft', }; } @@ -29,6 +33,8 @@ public function color(): string self::Approved => 'success', self::Rejected => 'danger', self::DescriptionUpdated => 'gray', + self::Withdrawn => 'warning', + self::ReturnedToDraft => 'warning', }; } @@ -40,6 +46,8 @@ public function icon(): string self::Approved => 'heroicon-o-check-circle', self::Rejected => 'heroicon-o-x-circle', self::DescriptionUpdated => 'heroicon-o-pencil-square', + self::Withdrawn => 'heroicon-o-arrow-uturn-left', + self::ReturnedToDraft => 'heroicon-o-arrow-uturn-left', }; } } diff --git a/app/Enums/PluginStatus.php b/app/Enums/PluginStatus.php index 4fd44020..1154fc15 100644 --- a/app/Enums/PluginStatus.php +++ b/app/Enums/PluginStatus.php @@ -4,6 +4,7 @@ enum PluginStatus: string { + case Draft = 'draft'; case Pending = 'pending'; case Approved = 'approved'; case Rejected = 'rejected'; @@ -11,6 +12,7 @@ enum PluginStatus: string public function label(): string { return match ($this) { + self::Draft => 'Draft', self::Pending => 'Pending Review', self::Approved => 'Approved', self::Rejected => 'Rejected', @@ -20,6 +22,7 @@ public function label(): string public function color(): string { return match ($this) { + self::Draft => 'zinc', self::Pending => 'yellow', self::Approved => 'green', self::Rejected => 'red', diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index 550d99ca..5e4be420 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -245,6 +245,7 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('status') ->badge() ->color(fn (PluginStatus $state): string => match ($state) { + PluginStatus::Draft => 'gray', PluginStatus::Pending => 'warning', PluginStatus::Approved => 'success', PluginStatus::Rejected => 'danger', diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php index ba97676c..b69c70eb 100644 --- a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php +++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php @@ -2,9 +2,11 @@ namespace App\Filament\Resources\PluginResource\Pages; +use App\Enums\PluginStatus; use App\Filament\Resources\PluginResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; +use Illuminate\Database\Eloquent\Builder; class ListPlugins extends ListRecords { @@ -16,4 +18,10 @@ protected function getHeaderActions(): array Actions\CreateAction::make(), ]; } + + protected function getTableQuery(): ?Builder + { + return parent::getTableQuery() + ->where('status', '!=', PluginStatus::Draft); + } } diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 1f891091..daf32221 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -421,6 +421,7 @@ public function status(Request $request, string $sessionId): JsonResponse 'id' => $license->id, 'plugin_id' => $license->plugin->id, 'plugin_name' => $license->plugin->name, + 'plugin_display_name' => $license->plugin->display_name, 'plugin_slug' => $license->plugin->slug, ]), 'products' => $productLicenses->map(fn ($license) => [ diff --git a/app/Http/Controllers/PluginDirectoryController.php b/app/Http/Controllers/PluginDirectoryController.php index 4b0855aa..6e2496bb 100644 --- a/app/Http/Controllers/PluginDirectoryController.php +++ b/app/Http/Controllers/PluginDirectoryController.php @@ -15,6 +15,7 @@ public function index(): View $featuredPlugins = Plugin::query() ->approved() + ->where('is_active', true) ->featured() ->latest() ->take(16) @@ -24,6 +25,7 @@ public function index(): View $latestPlugins = Plugin::query() ->approved() + ->where('is_active', true) ->where('featured', false) ->latest() ->take(16) @@ -52,11 +54,12 @@ public function show(string $vendor, string $package): View $user = Auth::user(); $isAdmin = $user?->isAdmin() ?? false; + $isOwner = $user && $plugin->user_id === $user->id; - abort_unless($plugin->isApproved() || $isAdmin, 404); + abort_unless(($plugin->isApproved() && $plugin->is_active) || $isAdmin || $isOwner, 404); - // For paid plugins, check if user has an accessible price (admins bypass) - if (! $isAdmin && $plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) { + // For paid plugins, check if user has an accessible price (admins and owners bypass) + if (! $isAdmin && ! $isOwner && $plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) { abort(404); } @@ -74,7 +77,7 @@ public function show(string $vendor, string $package): View 'bestPrice' => $bestPrice, 'regularPrice' => $regularPrice, 'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->id !== $regularPrice->id, - 'isAdminPreview' => ! $plugin->isApproved(), + 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner), ]); } diff --git a/app/Livewire/Customer/Plugins/Create.php b/app/Livewire/Customer/Plugins/Create.php index 4ede4e2f..5e7656dd 100644 --- a/app/Livewire/Customer/Plugins/Create.php +++ b/app/Livewire/Customer/Plugins/Create.php @@ -4,9 +4,7 @@ use App\Enums\PluginStatus; use App\Features\AllowPaidPlugins; -use App\Jobs\ReviewPluginRepository; use App\Models\Plugin; -use App\Notifications\PluginSubmitted; use App\Services\GitHubUserService; use App\Services\PluginSyncService; use Illuminate\Support\Facades\Cache; @@ -17,7 +15,7 @@ use Livewire\Component; #[Layout('components.layouts.dashboard')] -#[Title('Submit Your Plugin')] +#[Title('Create Your Plugin')] class Create extends Component { public string $pluginType = 'free'; @@ -26,10 +24,6 @@ class Create extends Component public string $repository = ''; - public string $notes = ''; - - public string $supportChannel = ''; - /** @var array */ public array $repositories = []; @@ -113,12 +107,12 @@ public function loadRepositories(): void $this->loadingRepos = false; } - public function submitPlugin(PluginSyncService $syncService): void + public function createPlugin(PluginSyncService $syncService): void { $user = auth()->user(); if (! $user->github_id) { - $this->addError('repository', 'You must connect your GitHub account to submit a plugin.'); + $this->addError('repository', 'You must connect your GitHub account to create a plugin.'); return; } @@ -132,26 +126,14 @@ public function submitPlugin(PluginSyncService $syncService): void function ($attribute, $value, $fail): void { $url = 'https://github.com/'.trim($value, '/'); if (Plugin::where('repository_url', $url)->exists()) { - $fail('This repository has already been submitted.'); + $fail('A plugin for this repository already exists.'); } }, ], 'pluginType' => ['required', 'string', 'in:free,paid'], - 'notes' => ['nullable', 'string', 'max:5000'], - 'supportChannel' => [ - 'required', - 'string', - 'max:255', - function (string $attribute, mixed $value, \Closure $fail) { - if (! filter_var($value, FILTER_VALIDATE_EMAIL) && ! filter_var($value, FILTER_VALIDATE_URL)) { - $fail('The support channel must be a valid email address or URL.'); - } - }, - ], ], [ 'repository.required' => 'Please select a repository for your plugin.', 'repository.regex' => 'Please enter a valid repository in the format vendor/repo-name.', - 'supportChannel.required' => 'Please provide a support channel (email or URL) for your plugin.', ]); if ($this->pluginType === 'paid' && ! Feature::active(AllowPaidPlugins::class)) { @@ -195,31 +177,12 @@ function (string $attribute, mixed $value, \Closure $fail) { $plugin = $user->plugins()->create([ 'repository_url' => $repositoryUrl, 'type' => $this->pluginType, - 'status' => PluginStatus::Pending, + 'status' => PluginStatus::Draft, 'developer_account_id' => $developerAccountId, - 'notes' => $this->notes ?: null, - 'support_channel' => $this->supportChannel ?: null, ]); - $webhookSecret = $plugin->generateWebhookSecret(); - - $webhookInstalled = false; - if ($user->hasGitHubToken()) { - $webhookResult = $githubService->createWebhook( - $owner, - $repo, - $plugin->getWebhookUrl(), - $webhookSecret - ); - $webhookInstalled = $webhookResult['success']; - } - - $plugin->update(['webhook_installed' => $webhookInstalled]); - $syncService->sync($plugin); - (new ReviewPluginRepository($plugin))->handle(); - if (! $plugin->name) { $plugin->delete(); @@ -228,13 +191,6 @@ function (string $attribute, mixed $value, \Closure $fail) { return; } - $user->notify(new PluginSubmitted($plugin)); - - $successMessage = 'Your plugin has been submitted for review!'; - if (! $webhookInstalled) { - $successMessage .= ' Please set up the webhook manually to enable automatic syncing.'; - } - [$vendor, $package] = explode('/', $plugin->name); $this->redirect( @@ -242,7 +198,7 @@ function (string $attribute, mixed $value, \Closure $fail) { navigate: true ); - session()->flash('success', $successMessage); + session()->flash('success', 'Your plugin has been created as a draft. You can edit it and submit for review when ready.'); } public function render() diff --git a/app/Livewire/Customer/Plugins/Index.php b/app/Livewire/Customer/Plugins/Index.php index a9849599..13f35556 100644 --- a/app/Livewire/Customer/Plugins/Index.php +++ b/app/Livewire/Customer/Plugins/Index.php @@ -16,7 +16,7 @@ class Index extends Component { #[Url] - public string $status = 'pending'; + public string $status = 'draft'; #[Computed] public function plugins(): Collection @@ -37,6 +37,7 @@ public function pluginCounts(): array ->toArray(); return [ + PluginStatus::Draft->value => $counts[PluginStatus::Draft->value] ?? 0, PluginStatus::Approved->value => $counts[PluginStatus::Approved->value] ?? 0, PluginStatus::Pending->value => $counts[PluginStatus::Pending->value] ?? 0, PluginStatus::Rejected->value => $counts[PluginStatus::Rejected->value] ?? 0, diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index cba78a57..83ee313a 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -2,7 +2,12 @@ namespace App\Livewire\Customer\Plugins; +use App\Enums\PluginTier; +use App\Enums\PluginType; +use App\Jobs\ReviewPluginRepository; use App\Models\Plugin; +use App\Notifications\PluginSubmitted; +use App\Services\GitHubUserService; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -11,7 +16,7 @@ use Livewire\WithFileUploads; #[Layout('components.layouts.dashboard')] -#[Title('Edit Plugin')] +#[Title('Plugin')] class Show extends Component { use WithFileUploads; @@ -31,8 +36,18 @@ class Show extends Component #[Validate('nullable|image|max:1024')] public $logo = null; + public ?string $displayName = null; + public ?string $supportChannel = null; + public string $notes = ''; + + public string $activeTab = 'details'; + + public string $pluginType = 'free'; + + public ?string $tier = null; + public function mount(string $vendor, string $package): void { $this->plugin = Plugin::findByVendorPackageOrFail($vendor, $package); @@ -41,27 +56,152 @@ public function mount(string $vendor, string $package): void abort(403); } + $this->displayName = $this->plugin->display_name; $this->description = $this->plugin->description; $this->iconName = $this->plugin->icon_name ?? 'cube'; $this->iconGradient = $this->plugin->icon_gradient; $this->iconMode = $this->plugin->hasLogo() ? 'upload' : 'gradient'; $this->supportChannel = $this->plugin->support_channel; + $this->notes = $this->plugin->notes ?? ''; + $this->pluginType = $this->plugin->type->value; + $this->tier = $this->plugin->tier?->value; } - public function updateDescription(): void + public function submitForReview(): void { - $this->validate([ - 'description' => ['nullable', 'string', 'max:1000'], - ]); + if (! $this->plugin->isDraft()) { + session()->flash('error', 'Only draft plugins can be submitted for review.'); + + return; + } + + if (! $this->plugin->support_channel) { + session()->flash('error', 'Please set a support channel before submitting for review.'); + + return; + } + if ($this->plugin->isPaid() && ! $this->plugin->tier) { + session()->flash('error', 'Please select a pricing tier for your paid plugin.'); + + return; + } + + $user = auth()->user(); + + // Install webhook + $repoInfo = $this->plugin->getRepositoryOwnerAndName(); + + if ($repoInfo && $user->hasGitHubToken()) { + $webhookSecret = $this->plugin->webhook_secret ?? $this->plugin->generateWebhookSecret(); + $githubService = GitHubUserService::for($user); + $webhookResult = $githubService->createWebhook( + $repoInfo['owner'], + $repoInfo['repo'], + $this->plugin->getWebhookUrl(), + $webhookSecret + ); + $this->plugin->update(['webhook_installed' => $webhookResult['success']]); + } + + // Run review checks + (new ReviewPluginRepository($this->plugin))->handle(); + + // Submit + $this->plugin->submit(); + $this->plugin->refresh(); + + // Notify + $user->notify(new PluginSubmitted($this->plugin)); + + session()->flash('success', 'Your plugin has been submitted for review!'); + } + + public function withdrawFromReview(): void + { + if (! $this->plugin->isPending()) { + session()->flash('error', 'Only pending plugins can be withdrawn.'); + + return; + } + + $this->plugin->withdraw(); + $this->plugin->refresh(); + + session()->flash('success', 'Your plugin has been withdrawn from review and returned to draft.'); + } + + public function returnToDraft(): void + { + if (! $this->plugin->isRejected()) { + session()->flash('error', 'Only rejected plugins can be returned to draft.'); + + return; + } + + $this->plugin->returnToDraft(); + $this->plugin->refresh(); + + session()->flash('success', 'Your plugin has been returned to draft. You can make changes and resubmit.'); + } + + public function save(): void + { + if (! $this->plugin->isDraft() && ! $this->plugin->isApproved()) { + session()->flash('error', 'You can only edit draft or approved plugins.'); + + return; + } + + $rules = [ + 'displayName' => ['nullable', 'string', 'max:250'], + 'description' => ['required', 'string', 'max:1000'], + 'supportChannel' => [ + 'required', + 'string', + 'max:255', + function (string $attribute, mixed $value, \Closure $fail) { + if ($value && ! filter_var($value, FILTER_VALIDATE_EMAIL) && ! filter_var($value, FILTER_VALIDATE_URL)) { + $fail('The support channel must be a valid email address or URL.'); + } + }, + ], + ]; + + if ($this->plugin->isDraft()) { + $rules['notes'] = ['nullable', 'string', 'max:5000']; + } + + $this->validate($rules); + + $data = [ + 'display_name' => $this->displayName ?: null, + 'support_channel' => $this->supportChannel, + ]; + + if ($this->plugin->isDraft()) { + $data['notes'] = $this->notes ?: null; + + $pluginType = PluginType::from($this->pluginType); + $data['type'] = $pluginType; + $data['tier'] = $pluginType === PluginType::Paid && $this->tier ? PluginTier::from($this->tier) : null; + } + + $this->plugin->update($data); $this->plugin->updateDescription($this->description, auth()->id()); $this->plugin->refresh(); - session()->flash('success', 'Plugin description updated successfully!'); + session()->flash('success', 'Plugin details saved successfully!'); } public function updateIcon(): void { + if (! $this->plugin->isDraft() && ! $this->plugin->isApproved()) { + session()->flash('error', 'You can only edit the icon for draft or approved plugins.'); + + return; + } + $this->validate([ 'iconGradient' => ['required', 'string', 'in:'.implode(',', array_keys(Plugin::gradientPresets()))], 'iconName' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9-]+$/'], @@ -84,6 +224,12 @@ public function updateIcon(): void public function uploadLogo(): void { + if (! $this->plugin->isDraft() && ! $this->plugin->isApproved()) { + session()->flash('error', 'You can only upload a logo for draft or approved plugins.'); + + return; + } + $this->validate([ 'logo' => ['required', 'image', 'max:1024', 'mimes:png,jpeg,jpg,svg,webp'], ]); @@ -109,6 +255,12 @@ public function uploadLogo(): void public function deleteIcon(): void { + if (! $this->plugin->isDraft() && ! $this->plugin->isApproved()) { + session()->flash('error', 'You can only remove the icon for draft or approved plugins.'); + + return; + } + if ($this->plugin->logo_path) { Storage::disk('public')->delete($this->plugin->logo_path); } @@ -125,44 +277,22 @@ public function deleteIcon(): void session()->flash('success', 'Plugin icon removed successfully!'); } - public function updateSupportChannel(): void - { - $this->validate([ - 'supportChannel' => [ - 'required', - 'string', - 'max:255', - function (string $attribute, mixed $value, \Closure $fail) { - if (! filter_var($value, FILTER_VALIDATE_EMAIL) && ! filter_var($value, FILTER_VALIDATE_URL)) { - $fail('The support channel must be a valid email address or URL.'); - } - }, - ], - ], [ - 'supportChannel.required' => 'Please provide a support channel (email or URL) for your plugin.', - ]); - - $this->plugin->update([ - 'support_channel' => $this->supportChannel, - ]); - - $this->plugin->refresh(); - - session()->flash('success', 'Support channel updated successfully!'); - } - - public function resubmit(): void + public function toggleListing(): void { - if (! $this->plugin->isRejected()) { - session()->flash('error', 'Only rejected plugins can be resubmitted.'); + if (! $this->plugin->isApproved()) { + session()->flash('error', 'Only approved plugins can be listed or de-listed.'); return; } - $this->plugin->resubmit(); + $this->plugin->update([ + 'is_active' => ! $this->plugin->is_active, + ]); + $this->plugin->refresh(); - session()->flash('success', 'Your plugin has been resubmitted for review!'); + $action = $this->plugin->is_active ? 'listed' : 'de-listed'; + session()->flash('success', "Your plugin has been {$action}."); } public function render() diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php index 60bf921c..b0f2b5b9 100644 --- a/app/Livewire/PluginDirectory.php +++ b/app/Livewire/PluginDirectory.php @@ -74,6 +74,7 @@ public function render(): View $plugins = Plugin::query() ->approved() + ->where('is_active', true) ->when($this->search, function ($query): void { $query->where(function ($q): void { $q->where('name', 'like', "%{$this->search}%") diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index e1715dc3..2f18a935 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -71,16 +71,6 @@ protected static function booted(): void } }); - static::created(function (Plugin $plugin): void { - $plugin->recordActivity( - PluginActivityType::Submitted, - null, - PluginStatus::Pending, - null, - $plugin->user_id - ); - }); - static::updated(function (Plugin $plugin): void { // When tier is set or changed, create/update prices automatically if ($plugin->wasChanged('tier') && $plugin->tier !== null) { @@ -268,6 +258,11 @@ public function isApproved(): bool return $this->status === PluginStatus::Approved; } + public function isDraft(): bool + { + return $this->status === PluginStatus::Draft; + } + public function isRejected(): bool { return $this->status === PluginStatus::Rejected; @@ -621,6 +616,74 @@ public function resubmit(): void ); } + /** + * Submit a draft plugin for review (Draft → Pending). + * Logs Resubmitted if previously rejected, otherwise Submitted. + */ + public function submit(): void + { + $previousStatus = $this->status; + + $wasRejected = $this->activities() + ->where('type', PluginActivityType::Rejected) + ->exists(); + + $this->update([ + 'status' => PluginStatus::Pending, + 'rejection_reason' => null, + 'approved_at' => null, + 'approved_by' => null, + ]); + + $this->recordActivity( + $wasRejected ? PluginActivityType::Resubmitted : PluginActivityType::Submitted, + $previousStatus, + PluginStatus::Pending, + null, + $this->user_id + ); + } + + /** + * Withdraw a pending plugin back to draft (Pending → Draft). + */ + public function withdraw(): void + { + $previousStatus = $this->status; + + $this->update([ + 'status' => PluginStatus::Draft, + ]); + + $this->recordActivity( + PluginActivityType::Withdrawn, + $previousStatus, + PluginStatus::Draft, + null, + $this->user_id + ); + } + + /** + * Return a rejected plugin to draft for editing (Rejected → Draft). + */ + public function returnToDraft(): void + { + $previousStatus = $this->status; + + $this->update([ + 'status' => PluginStatus::Draft, + ]); + + $this->recordActivity( + PluginActivityType::ReturnedToDraft, + $previousStatus, + PluginStatus::Draft, + null, + $this->user_id + ); + } + public function updateDescription(string $description, int $updatedById): void { $oldDescription = $this->description; diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index 8b0a9734..a66dafdd 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -121,6 +121,17 @@ public function definition(): array ]; } + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginStatus::Draft, + 'approved_at' => null, + 'approved_by' => null, + 'rejection_reason' => null, + 'webhook_secret' => null, + ]); + } + public function pending(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_04_08_180326_change_plugins_default_status_to_draft.php b/database/migrations/2026_04_08_180326_change_plugins_default_status_to_draft.php new file mode 100644 index 00000000..d102d329 --- /dev/null +++ b/database/migrations/2026_04_08_180326_change_plugins_default_status_to_draft.php @@ -0,0 +1,34 @@ +where('status', 'pending') + ->update(['status' => 'draft']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('plugins') + ->where('status', 'draft') + ->update(['status' => 'pending']); + } +}; diff --git a/database/migrations/2026_04_09_101159_add_display_name_to_plugins_table.php b/database/migrations/2026_04_09_101159_add_display_name_to_plugins_table.php new file mode 100644 index 00000000..9fe1a643 --- /dev/null +++ b/database/migrations/2026_04_09_101159_add_display_name_to_plugins_table.php @@ -0,0 +1,28 @@ +string('display_name', 250)->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('display_name'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 135b7064..c4c8703b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "brave-lemur", + "name": "lazy-podenco", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/views/cart/show.blade.php b/resources/views/cart/show.blade.php index b76e116b..82fc1920 100644 --- a/resources/views/cart/show.blade.php +++ b/resources/views/cart/show.blade.php @@ -223,11 +223,12 @@ class="flex gap-4 p-6"
-

+

- {{ $item->plugin->name }} + {{ $item->plugin->display_name ?? $item->plugin->name }}

+

{{ $item->plugin->name }}

by {{ $item->plugin->user->display_name }}

diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php index 9dc35fdf..5874c1a8 100644 --- a/resources/views/cart/success.blade.php +++ b/resources/views/cart/success.blade.php @@ -26,7 +26,8 @@
- {{ $item->plugin->name }} + {{ $item->plugin->display_name ?? $item->plugin->name }} +

{{ $item->plugin->name }}

@if ($item->plugin->isOfficial() && auth()->user()?->hasUltraAccess())

Included with Ultra

@endif @@ -45,7 +46,8 @@
- {{ $plugin->name }} + {{ $plugin->display_name ?? $plugin->name }} +

{{ $plugin->name }}

@endforeach @@ -209,7 +211,10 @@ class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-gr
- +
+ +

+
View Plugin diff --git a/resources/views/components/customer/status-badge.blade.php b/resources/views/components/customer/status-badge.blade.php index b1b64467..f878627e 100644 --- a/resources/views/components/customer/status-badge.blade.php +++ b/resources/views/components/customer/status-badge.blade.php @@ -7,7 +7,7 @@ 'Needs Renewal', 'In Progress' => 'blue', 'Suspended', 'Rejected', 'Closed' => 'red', 'Responded' => 'green', - 'On Hold' => 'zinc', + 'Draft', 'On Hold' => 'zinc', default => 'zinc', }; @endphp diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index 27911fb4..0da143a8 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -36,9 +36,10 @@ class="size-12 shrink-0 rounded-xl object-cover"
-

- {{ $plugin->name }} +

+ {{ $plugin->display_name ?? $plugin->name }}

+

{{ $plugin->name }}

@if ($plugin->description)

{{ $plugin->description }} diff --git a/resources/views/customer/team/show.blade.php b/resources/views/customer/team/show.blade.php index ab3c7b92..d79e97cc 100644 --- a/resources/views/customer/team/show.blade.php +++ b/resources/views/customer/team/show.blade.php @@ -53,8 +53,9 @@

- {{ $plugin->name }} + {{ $plugin->display_name ?? $plugin->name }} + {{ $plugin->name }} @if($plugin->description) {{ $plugin->description }} @endif diff --git a/resources/views/customer/ultra/index.blade.php b/resources/views/customer/ultra/index.blade.php index bfae9a1c..bf787171 100644 --- a/resources/views/customer/ultra/index.blade.php +++ b/resources/views/customer/ultra/index.blade.php @@ -151,8 +151,9 @@
- {{ $plugin->name }} + {{ $plugin->display_name ?? $plugin->name }} + {{ $plugin->name }} @if($plugin->description) {{ $plugin->description }} @endif diff --git a/resources/views/livewire/customer/developer/dashboard.blade.php b/resources/views/livewire/customer/developer/dashboard.blade.php index 60ebdae6..b78e2ea7 100644 --- a/resources/views/livewire/customer/developer/dashboard.blade.php +++ b/resources/views/livewire/customer/developer/dashboard.blade.php @@ -68,7 +68,8 @@
-

{{ $plugin->name }}

+

{{ $plugin->display_name ?? $plugin->name }}

+

{{ $plugin->name }}

{{ $plugin->licenses_count }} sales diff --git a/resources/views/livewire/customer/plugins/create.blade.php b/resources/views/livewire/customer/plugins/create.blade.php index 8622305b..dddcede4 100644 --- a/resources/views/livewire/customer/plugins/create.blade.php +++ b/resources/views/livewire/customer/plugins/create.blade.php @@ -11,11 +11,11 @@
  • - Submit Plugin + Create Plugin
  • - Submit Your Plugin + Create Your Plugin Add your plugin to the NativePHP Plugin Marketplace
    @@ -27,26 +27,12 @@ @endif - {{-- Validation Errors --}} - @if ($errors->any()) - - Please fix the following errors: - -
      - @foreach ($errors->all() as $error) -
    • {{ $error }}
    • - @endforeach -
    -
    -
    - @endif - {{-- GitHub Connection Required --}} @if (!auth()->user()->github_id) GitHub Connection Required - To submit a plugin, you need to connect your GitHub account so we can access your repository and automatically set up webhooks. + To create a plugin, you need to connect your GitHub account so we can access your repository. @@ -56,7 +42,7 @@ @else -
    + {{-- Plugin Type --}} @feature(App\Features\AllowPaidPlugins::class) @@ -69,7 +55,7 @@ Free Plugin - Open source, hosted on Packagist/GitHub + Open source, hosted on Packagist @@ -102,7 +88,7 @@ Select Repository - Choose the repository containing your plugin. We'll automatically set up a webhook to keep your plugin in sync. + Choose the repository containing your plugin.
    @@ -126,9 +112,6 @@ @endif @endif - @error('repository') - {{ $message }} - @enderror
    @@ -159,36 +142,10 @@ @endif @endfeature - @if($repository) - {{-- Support Channel --}} - - Support Channel - - How can users get support for your plugin? Provide an email address or a URL. If you enter a URL, ensure that it clearly details how a visitor goes about getting support for this plugin. - - -
    - -
    -
    - - {{-- Notes --}} - - Notes - - Any notes for the review team? Feel free to share links to videos of the plugin working. These won't be displayed on your plugin listing. - - -
    - -
    -
    - @endif - {{-- Submit Button --}}
    Cancel - Submit Plugin + Create Plugin
    @endif diff --git a/resources/views/livewire/customer/plugins/index.blade.php b/resources/views/livewire/customer/plugins/index.blade.php index d8da94d0..1ce72295 100644 --- a/resources/views/livewire/customer/plugins/index.blade.php +++ b/resources/views/livewire/customer/plugins/index.blade.php @@ -6,19 +6,19 @@ {{-- Action Cards --}}
    - {{-- Submit Plugin Card --}} + {{-- Create Plugin Card --}}
    - Submit Your Plugin + Create Your Plugin - Built a plugin? Submit it to the NativePHP Plugin Marketplace and share it with the community. + Built a plugin? Add it to the NativePHP Plugin Marketplace and share it with the community. - Submit a Plugin + Create a Plugin
    @@ -61,12 +61,13 @@ @endif - {{-- Submitted Plugins List --}} + {{-- Plugins List --}}
    @@ -22,49 +38,63 @@ @endif - {{-- Rejection Reason --}} - @if ($plugin->isRejected() && $plugin->rejection_reason) + {{-- Status-specific banners --}} + @if ($plugin->isDraft()) + + Draft Plugin + This plugin is a draft. Edit the details below, then submit for review when ready. + + @elseif ($plugin->isPending()) + + Under Review + Your plugin is currently being reviewed. You can withdraw it to make changes. + + Withdraw from Review + + + @elseif ($plugin->isRejected() && $plugin->rejection_reason) Rejection Reason {{ $plugin->rejection_reason }} - Resubmit for Review + Return to Draft - @endif - - {{-- Plugin Status --}} - -
    -
    - @if ($plugin->hasLogo()) - {{ $plugin->name }} logo - @elseif ($plugin->hasGradientIcon()) -
    - -
    - @else -
    - -
    - @endif + @elseif ($plugin->isApproved()) + +
    - {{ $plugin->name }} - - {{ $plugin->type->label() }} plugin - @if ($plugin->latest_version) - - v{{ $plugin->latest_version }} + Listing Status + + @if ($plugin->is_active) + Your plugin is publicly listed in the directory. + @else + Your plugin is de-listed and hidden from the directory. @endif
    +
    - -
    - + + @endif + + {{-- Plugin Status (hidden for Pending/Rejected — details card covers this) --}} + @if ($plugin->isDraft() || $plugin->isApproved()) + + +
    +
    + + {{ $plugin->name }} +
    + +
    +
    +
    + @endif - {{-- Review Checks --}} - @if ($plugin->review_checks) + {{-- Review Checks (show for Pending, Rejected, Approved — not Draft) --}} + @if (! $plugin->isDraft() && $plugin->review_checks) Review Checks Automated checks run against your repository. @@ -163,140 +193,464 @@ @endif - {{-- Support Channel --}} - - Support Channel - How can users get support for your plugin? Provide an email address or a URL. - -
    - - @error('supportChannel') - {{ $message }} - @enderror - -
    - Save Support Channel -
    - -
    + {{-- Editable fields for Draft plugins (with tabs) --}} + @if ($plugin->isDraft()) + + + Details + Submit for Review + - {{-- Plugin Icon --}} - - Plugin Icon - Choose a gradient and icon, or upload your own logo. + +
    + {{-- Plugin Type --}} + @feature(App\Features\AllowPaidPlugins::class) + + Type + Is your plugin free or paid? -
    - {{-- Current Icon Preview --}} - @if ($plugin->hasCustomIcon()) -
    - @if ($plugin->hasLogo()) - {{ $plugin->name }} logo - @elseif ($plugin->hasGradientIcon()) -
    - +
    + + +
    - @endif - Remove icon -
    - @endif + + + {{-- Pricing Tier (only when paid) --}} + @if ($pluginType === 'paid') + + Pricing Tier + Choose a pricing tier for your plugin. - {{-- Gradient Icon Picker --}} -
    - -
    -
    - -
    - @foreach (\App\Models\Plugin::gradientPresets() as $key => $classes) -
    + +
    + {{-- Notes --}} + + Notes + Any notes for the review team? These won't be displayed on your plugin listing. - {{-- Custom Logo Upload --}} -
    -
    -
    - -
    - + - Upload
    - @error('logo') - {{ $message }} - @enderror - PNG, JPG, SVG, or WebP. Max 1MB. Recommended: 256x256 pixels, square. + + + {{-- Submit Button --}} +
    + Submit for Review
    - +
    + + + @elseif ($plugin->isApproved()) + {{-- Editable fields for Approved plugins (no tabs) --}} +
    + {{-- Display Name --}} + + Name (optional) + A display name for your plugin. If not set, your Composer package name will be used. - - -
    -
    - - - {{-- Description Form --}} - - Plugin Description - Describe what your plugin does. This will be displayed in the plugin directory. - - - - @error('description') - {{ $message }} - @enderror - Maximum 1000 characters - -
    - Save Description +
    + + Maximum 250 characters +
    + + + {{-- Support Channel --}} + + Support + How can users get support for your plugin? Provide an email address or a URL. + +
    + + @error('supportChannel') + {{ $message }} + @enderror +
    +
    + + {{-- Description --}} + + Description + Describe what your plugin does. This will be displayed in the plugin directory. + +
    + + @error('description') + {{ $message }} + @enderror + Maximum 1000 characters +
    +
    + + {{-- Icon --}} + + Icon + Choose a gradient and icon, or upload your own logo. + +
    + {{-- Current Icon Preview --}} + @if ($plugin->hasCustomIcon()) +
    + @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @elseif ($plugin->hasGradientIcon()) +
    + +
    + @endif + Remove icon +
    + @endif + + {{-- Gradient Icon Picker --}} +
    +
    +
    + +
    + @foreach (\App\Models\Plugin::gradientPresets() as $key => $classes) + + @endforeach +
    + @error('iconGradient') + {{ $message }} + @enderror +
    + + + @error('iconName') + {{ $message }} + @enderror + + Save Icon +
    + + + +
    + + {{-- Custom Logo Upload --}} +
    +
    +
    + +
    + + Upload +
    + @error('logo') + {{ $message }} + @enderror + PNG, JPG, SVG, or WebP. Max 1MB. Recommended: 256x256 pixels, square. +
    +
    + + + +
    +
    +
    + + {{-- Save Button --}} +
    + Save Changes
    - + @else + {{-- Read-only display for Pending/Rejected --}} + +
    +
    + @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @elseif ($plugin->hasGradientIcon()) +
    + +
    + @else +
    + +
    + @endif +
    + {{ $plugin->display_name ?? $plugin->name }} + @if ($plugin->description) + {{ $plugin->description }} + @else + No description provided + @endif +
    +
    + @if ($plugin->isPaid() && $plugin->tier) + @php + $regularPrice = $plugin->tier->getPrices()[\App\Enums\PriceTier::Regular->value] / 100; + @endphp + + {{ $plugin->tier->label() }} — ${{ number_format($regularPrice) }} + + @elseif ($plugin->isPaid()) + + Paid + + @else + + Free + + @endif +
    + + + +
    +
    + Support Channel + @if ($plugin->support_channel) + {{ $plugin->support_channel }} + @else + No support channel set + @endif +
    + + +
    +
    + + @if ($plugin->notes) + + Submission Notes + {{ $plugin->notes }} + + @endif + @endif
    diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 6dbe932e..03ec8de0 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -1,4 +1,4 @@ - +

    - Admin Preview — This plugin is not yet published. Status: {{ $plugin->status->label() }} + Preview — This plugin is not publicly visible. + @if ($plugin->isApproved() && ! $plugin->is_active) + It has been de-listed. + @else + Status: {{ $plugin->status->label() }} + @endif

    @endif @@ -78,10 +83,11 @@ class="size-16 shrink-0 rounded-2xl object-cover"

    - {{ $plugin->name }} + {{ $plugin->display_name ?? $plugin->name }}

    +

    {{ $plugin->name }}

    @if ($plugin->description)

    {{ $plugin->description }} @@ -270,6 +276,35 @@ class="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:

    + {{-- Support --}} + @if ($plugin->support_channel) +
    +
    Support
    +
    + @if (filter_var($plugin->support_channel, FILTER_VALIDATE_URL)) + + {{ $plugin->support_channel }} + + + @elseif (filter_var($plugin->support_channel, FILTER_VALIDATE_EMAIL)) + + {{ $plugin->support_channel }} + + + @else + {{ $plugin->support_channel }} + @endif +
    +
    + @endif + {{-- Links (only for free plugins with repository) --}} diff --git a/routes/web.php b/routes/web.php index 38bc0c86..1b1d19f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -454,7 +454,7 @@ // Plugin management (keeps customer.plugins.* route names) Route::name('customer.')->group(function (): void { Route::livewire('plugins', App\Livewire\Customer\Plugins\Index::class)->name('plugins.index'); - Route::livewire('plugins/submit', App\Livewire\Customer\Plugins\Create::class)->name('plugins.create'); + Route::livewire('plugins/create', App\Livewire\Customer\Plugins\Create::class)->name('plugins.create'); Route::livewire('plugins/{vendor}/{package}', App\Livewire\Customer\Plugins\Show::class)->name('plugins.show'); }); }); diff --git a/tests/Feature/CustomerPluginReviewChecksTest.php b/tests/Feature/CustomerPluginReviewChecksTest.php index 1d38e97d..7d23f360 100644 --- a/tests/Feature/CustomerPluginReviewChecksTest.php +++ b/tests/Feature/CustomerPluginReviewChecksTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Livewire\Customer\Plugins\Create; +use App\Livewire\Customer\Plugins\Show; use App\Models\DeveloperAccount; use App\Models\User; use App\Notifications\PluginSubmitted; @@ -16,23 +17,11 @@ class CustomerPluginReviewChecksTest extends TestCase { use RefreshDatabase; - /** @test */ - public function submitting_a_plugin_runs_review_checks(): void + private function fakeGitHubForCreateAndSubmit(string $repoSlug): void { - Notification::fake(); - - $user = User::factory()->create([ - 'github_id' => '12345', - 'github_token' => encrypt('fake-token'), - ]); - DeveloperAccount::factory()->withAcceptedTerms()->create([ - 'user_id' => $user->id, - ]); - - $repoSlug = 'acme/test-plugin'; $base = "https://api.github.com/repos/{$repoSlug}"; $composerJson = json_encode([ - 'name' => 'acme/test-plugin', + 'name' => $repoSlug, 'description' => 'A test plugin', 'require' => [ 'php' => '^8.1', @@ -75,18 +64,51 @@ public function submitting_a_plugin_runs_review_checks(): void 'encoding' => 'base64', ]), ]); + } + + /** @test */ + public function submitting_a_plugin_for_review_runs_review_checks(): void + { + Notification::fake(); + $user = User::factory()->create([ + 'github_id' => '12345', + 'github_token' => encrypt('fake-token'), + ]); + DeveloperAccount::factory()->withAcceptedTerms()->create([ + 'user_id' => $user->id, + ]); + + $repoSlug = 'acme/test-plugin'; + $this->fakeGitHubForCreateAndSubmit($repoSlug); + + // Step 1: Create the draft Livewire::actingAs($user) ->test(Create::class) ->set('repository', $repoSlug) ->set('pluginType', 'free') - ->set('supportChannel', 'dev@testplugin.io') - ->call('submitPlugin') + ->call('createPlugin') ->assertRedirect(); $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); + $this->assertNotNull($plugin, 'Plugin should exist after creation'); + $this->assertEquals('draft', $plugin->status->value); + + // Set support channel (required before submission) + $plugin->update(['support_channel' => 'dev@testplugin.io']); + + // Re-fake HTTP for the submission step + $this->fakeGitHubForCreateAndSubmit($repoSlug); - $this->assertNotNull($plugin, 'Plugin should exist after submission'); + // Step 2: Submit for review from the Show page + [$vendor, $package] = explode('/', $plugin->name); + Livewire::actingAs($user) + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->call('submitForReview'); + + $plugin->refresh(); + + $this->assertEquals('pending', $plugin->status->value); $this->assertNotNull($plugin->review_checks, 'review_checks should be populated'); $this->assertTrue($plugin->review_checks['has_license_file']); $this->assertTrue($plugin->review_checks['has_release_version']); @@ -138,10 +160,7 @@ public function plugin_submitted_email_includes_failing_checks(): void "{$base}/releases/latest" => Http::response([], 404), "{$base}/tags*" => Http::response([]), "https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404), - - // Webhook creation (fails) "{$base}/hooks" => Http::response([], 422), - $base => Http::response(['default_branch' => 'main']), "{$base}/git/trees/main*" => Http::response([ 'tree' => [ @@ -154,15 +173,54 @@ public function plugin_submitted_email_includes_failing_checks(): void ]), ]); + // Step 1: Create the draft Livewire::actingAs($user) ->test(Create::class) ->set('repository', $repoSlug) ->set('pluginType', 'free') - ->set('supportChannel', 'support@bare-plugin.io') - ->call('submitPlugin'); + ->call('createPlugin'); $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); + // Set support channel (required before submission) + $plugin->update(['support_channel' => 'support@bare-plugin.io']); + + // Re-fake HTTP for submission + Http::fake([ + "{$base}/contents/composer.json*" => Http::response([ + 'content' => base64_encode($composerJson), + 'encoding' => 'base64', + ]), + "{$base}/contents/README.md" => Http::response([ + 'content' => base64_encode('# Bare Plugin'), + 'encoding' => 'base64', + ]), + "{$base}/contents/nativephp.json" => Http::response([], 404), + "{$base}/contents/LICENSE*" => Http::response([], 404), + "{$base}/releases/latest" => Http::response([], 404), + "{$base}/tags*" => Http::response([]), + "{$base}/hooks" => Http::response([], 422), + $base => Http::response(['default_branch' => 'main']), + "{$base}/git/trees/main*" => Http::response([ + 'tree' => [ + ['path' => 'src/ServiceProvider.php', 'type' => 'blob'], + ], + ]), + "{$base}/readme" => Http::response([ + 'content' => base64_encode('# Bare Plugin'), + 'encoding' => 'base64', + ]), + "https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404), + ]); + + // Step 2: Submit for review + [$vendor, $package] = explode('/', $plugin->name); + Livewire::actingAs($user) + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->call('submitForReview'); + + $plugin->refresh(); + Notification::assertSentTo($user, PluginSubmitted::class, function (PluginSubmitted $notification) use ($plugin) { $mail = $notification->toMail($plugin->user); $rendered = $mail->render()->toHtml(); diff --git a/tests/Feature/DeveloperTermsTest.php b/tests/Feature/DeveloperTermsTest.php index f7986f2e..279dc304 100644 --- a/tests/Feature/DeveloperTermsTest.php +++ b/tests/Feature/DeveloperTermsTest.php @@ -230,7 +230,7 @@ public function plugin_create_page_renders_for_github_connected_user(): void Livewire::actingAs($user) ->test(Create::class) ->assertStatus(200) - ->assertSee('Submit Your Plugin') + ->assertSee('Create Your Plugin') ->assertSee('Select Repository'); } diff --git a/tests/Feature/Livewire/Customer/PluginCreateTest.php b/tests/Feature/Livewire/Customer/PluginCreateTest.php index a425283b..0cbbe2c0 100644 --- a/tests/Feature/Livewire/Customer/PluginCreateTest.php +++ b/tests/Feature/Livewire/Customer/PluginCreateTest.php @@ -136,7 +136,7 @@ public function test_no_owner_selected_returns_empty_repositories(): void // Namespace Validation Tests // ======================================== - public function test_submission_blocked_when_namespace_claimed_by_another_user(): void + public function test_creation_blocked_when_namespace_claimed_by_another_user(): void { $existingUser = User::factory()->create(); Plugin::factory()->for($existingUser)->create(['name' => 'acme/existing-plugin']); @@ -148,7 +148,7 @@ public function test_submission_blocked_when_namespace_claimed_by_another_user() Livewire::actingAs($user)->test(Create::class) ->set('repository', 'acme/new-plugin') ->set('pluginType', 'free') - ->call('submitPlugin') + ->call('createPlugin') ->assertNoRedirect(); $this->assertDatabaseMissing('plugins', [ @@ -156,7 +156,7 @@ public function test_submission_blocked_when_namespace_claimed_by_another_user() ]); } - public function test_submission_blocked_for_reserved_namespace(): void + public function test_creation_blocked_for_reserved_namespace(): void { $user = $this->createGitHubUser(); @@ -165,7 +165,7 @@ public function test_submission_blocked_for_reserved_namespace(): void Livewire::actingAs($user)->test(Create::class) ->set('repository', 'nativephp/my-plugin') ->set('pluginType', 'free') - ->call('submitPlugin') + ->call('createPlugin') ->assertNoRedirect(); $this->assertDatabaseMissing('plugins', [ @@ -173,7 +173,7 @@ public function test_submission_blocked_for_reserved_namespace(): void ]); } - public function test_submission_allowed_for_own_namespace(): void + public function test_creation_allowed_for_own_namespace(): void { $user = $this->createGitHubUser(); Plugin::factory()->for($user)->create(['name' => 'myvendor/first-plugin']); @@ -184,23 +184,22 @@ public function test_submission_allowed_for_own_namespace(): void 'api.github.com/repos/myvendor/second-plugin/contents/composer.json*' => Http::response([ 'content' => $composerJson, ]), - 'api.github.com/repos/myvendor/second-plugin/hooks' => Http::response(['id' => 1]), 'api.github.com/*' => Http::response([], 404), ]); Livewire::actingAs($user)->test(Create::class) ->set('repository', 'myvendor/second-plugin') ->set('pluginType', 'free') - ->set('supportChannel', 'support@myvendor.io') - ->call('submitPlugin'); + ->call('createPlugin'); $this->assertDatabaseHas('plugins', [ 'repository_url' => 'https://github.com/myvendor/second-plugin', 'user_id' => $user->id, + 'status' => 'draft', ]); } - public function test_submission_blocked_when_composer_json_missing(): void + public function test_creation_blocked_when_composer_json_missing(): void { $user = $this->createGitHubUser(); @@ -211,11 +210,39 @@ public function test_submission_blocked_when_composer_json_missing(): void Livewire::actingAs($user)->test(Create::class) ->set('repository', 'testuser/no-composer') ->set('pluginType', 'free') - ->call('submitPlugin') + ->call('createPlugin') ->assertNoRedirect(); $this->assertDatabaseMissing('plugins', [ 'repository_url' => 'https://github.com/testuser/no-composer', ]); } + + public function test_plugin_created_as_draft(): void + { + $user = $this->createGitHubUser(); + + $composerJson = base64_encode(json_encode(['name' => 'testuser/draft-plugin'])); + + Http::fake([ + 'api.github.com/repos/testuser/draft-plugin/contents/composer.json*' => Http::response([ + 'content' => $composerJson, + ]), + 'api.github.com/*' => Http::response([], 404), + ]); + + Livewire::actingAs($user)->test(Create::class) + ->set('repository', 'testuser/draft-plugin') + ->set('pluginType', 'free') + ->call('createPlugin'); + + $this->assertDatabaseHas('plugins', [ + 'repository_url' => 'https://github.com/testuser/draft-plugin', + 'status' => 'draft', + ]); + + // No webhook should be installed for drafts + $plugin = Plugin::where('repository_url', 'https://github.com/testuser/draft-plugin')->first(); + $this->assertNull($plugin->webhook_secret); + } } diff --git a/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php new file mode 100644 index 00000000..5e96ae48 --- /dev/null +++ b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php @@ -0,0 +1,442 @@ +create([ + 'github_id' => '12345', + 'github_username' => 'testuser', + 'github_token' => encrypt('fake-token'), + ]); + } + + private function createDraftPlugin(User $user, ?string $supportChannel = 'support@test.io'): Plugin + { + return Plugin::factory()->draft()->for($user)->create([ + 'name' => 'testuser/my-plugin-'.fake()->unique()->numberBetween(100, 999999), + 'repository_url' => 'https://github.com/testuser/my-plugin-'.fake()->unique()->numberBetween(100, 999999), + 'support_channel' => $supportChannel, + ]); + } + + private function fakeGitHubForSubmission(Plugin $plugin): void + { + $repoInfo = $plugin->getRepositoryOwnerAndName(); + $base = "https://api.github.com/repos/{$repoInfo['owner']}/{$repoInfo['repo']}"; + + Http::fake([ + "{$base}/hooks" => Http::response(['id' => 1], 201), + $base => Http::response(['default_branch' => 'main']), + "{$base}/git/trees/main*" => Http::response([ + 'tree' => [ + ['path' => 'src/ServiceProvider.php', 'type' => 'blob'], + ], + ]), + "{$base}/contents/composer.json*" => Http::response([ + 'content' => base64_encode(json_encode(['name' => $plugin->name])), + 'encoding' => 'base64', + ]), + "{$base}/contents/LICENSE*" => Http::response([], 404), + "{$base}/releases/latest" => Http::response([], 404), + "{$base}/tags*" => Http::response([]), + "{$base}/readme" => Http::response([ + 'content' => base64_encode('# Plugin'), + 'encoding' => 'base64', + ]), + "{$base}/contents/README.md*" => Http::response([ + 'content' => base64_encode('# Plugin'), + 'encoding' => 'base64', + ]), + "{$base}/contents/nativephp.json*" => Http::response([], 404), + 'https://raw.githubusercontent.com/*' => Http::response('', 404), + ]); + } + + private function mountShowComponent(User $user, Plugin $plugin): Testable + { + [$vendor, $package] = explode('/', $plugin->name); + + return Livewire::actingAs($user)->test(Show::class, [ + 'vendor' => $vendor, + 'package' => $package, + ]); + } + + // ======================================== + // Submit for Review (Draft → Pending) + // ======================================== + + public function test_submit_draft_for_review(): void + { + Notification::fake(); + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + $this->fakeGitHubForSubmission($plugin); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + $this->assertNotNull($plugin->reviewed_at); + + Notification::assertSentTo($user, PluginSubmitted::class); + } + + public function test_submit_requires_support_channel(): void + { + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user, supportChannel: null); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Draft, $plugin->status); + } + + public function test_cannot_submit_non_draft_plugin(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->pending()->for($user)->create([ + 'name' => 'testuser/pending-plugin', + 'support_channel' => 'support@test.io', + ]); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + } + + public function test_resubmit_after_rejection_logs_resubmitted_activity(): void + { + Notification::fake(); + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + + // Simulate a rejection in the activity history + $plugin->activities()->create([ + 'type' => PluginActivityType::Rejected, + 'from_status' => 'pending', + 'to_status' => 'rejected', + 'note' => 'Test rejection', + 'causer_id' => $user->id, + ]); + + $this->fakeGitHubForSubmission($plugin); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + + // Should log Resubmitted, not Submitted + $latestActivity = $plugin->activities()->latest()->first(); + $this->assertEquals(PluginActivityType::Resubmitted, $latestActivity->type); + } + + // ======================================== + // Withdraw from Review (Pending → Draft) + // ======================================== + + public function test_withdraw_from_review(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->pending()->for($user)->create([ + 'name' => 'testuser/withdraw-plugin', + ]); + + $this->mountShowComponent($user, $plugin) + ->call('withdrawFromReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Draft, $plugin->status); + + $latestActivity = $plugin->activities()->latest()->first(); + $this->assertEquals(PluginActivityType::Withdrawn, $latestActivity->type); + } + + public function test_cannot_withdraw_non_pending_plugin(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->draft()->for($user)->create([ + 'name' => 'testuser/draft-plugin', + ]); + + $this->mountShowComponent($user, $plugin) + ->call('withdrawFromReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Draft, $plugin->status); + $this->assertCount(0, $plugin->activities); + } + + // ======================================== + // Return to Draft (Rejected → Draft) + // ======================================== + + public function test_return_rejected_to_draft(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->rejected()->for($user)->create([ + 'name' => 'testuser/rejected-plugin', + ]); + + $this->mountShowComponent($user, $plugin) + ->call('returnToDraft'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Draft, $plugin->status); + + $latestActivity = $plugin->activities()->latest()->first(); + $this->assertEquals(PluginActivityType::ReturnedToDraft, $latestActivity->type); + } + + public function test_cannot_return_non_rejected_to_draft(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->pending()->for($user)->create([ + 'name' => 'testuser/pending-for-return', + ]); + + $this->mountShowComponent($user, $plugin) + ->call('returnToDraft'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + } + + // ======================================== + // Edit Guards + // ======================================== + + public function test_draft_plugin_details_editable(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->draft()->for($user)->create([ + 'name' => 'testuser/details-draft', + ]); + + $this->mountShowComponent($user, $plugin) + ->set('displayName', 'My Awesome Plugin') + ->set('description', 'New description') + ->set('supportChannel', 'new@support.io') + ->set('notes', 'Updated notes') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals('My Awesome Plugin', $plugin->display_name); + $this->assertEquals('New description', $plugin->description); + $this->assertEquals('new@support.io', $plugin->support_channel); + $this->assertEquals('Updated notes', $plugin->notes); + } + + public function test_pending_plugin_details_not_editable(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->pending()->for($user)->create([ + 'name' => 'testuser/details-pending', + 'display_name' => 'Original Name', + 'description' => 'Original', + 'support_channel' => 'original@support.io', + 'notes' => 'Original notes', + ]); + + $this->mountShowComponent($user, $plugin) + ->set('displayName', 'Changed Name') + ->set('description', 'Changed') + ->set('supportChannel', 'changed@support.io') + ->set('notes', 'Changed notes') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals('Original Name', $plugin->display_name); + $this->assertEquals('Original', $plugin->description); + $this->assertEquals('original@support.io', $plugin->support_channel); + $this->assertEquals('Original notes', $plugin->notes); + } + + public function test_approved_plugin_details_editable(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->approved()->for($user)->create([ + 'name' => 'testuser/details-approved', + 'notes' => 'Old notes', + ]); + + $this->mountShowComponent($user, $plugin) + ->set('description', 'Updated approved description') + ->set('supportChannel', 'updated@support.io') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals('Updated approved description', $plugin->description); + $this->assertEquals('updated@support.io', $plugin->support_channel); + // Notes should not change for approved plugins + $this->assertEquals('Old notes', $plugin->notes); + } + + // ======================================== + // Toggle Listing (Approved only) + // ======================================== + + public function test_toggle_listing_on_approved_plugin(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->approved()->for($user)->create([ + 'name' => 'testuser/toggle-approved', + 'is_active' => true, + ]); + + $this->mountShowComponent($user, $plugin) + ->call('toggleListing'); + + $plugin->refresh(); + $this->assertFalse($plugin->is_active); + + // Toggle back + $this->mountShowComponent($user, $plugin) + ->call('toggleListing'); + + $plugin->refresh(); + $this->assertTrue($plugin->is_active); + } + + public function test_cannot_toggle_listing_on_non_approved_plugin(): void + { + $user = $this->createGitHubUser(); + $plugin = Plugin::factory()->draft()->for($user)->create([ + 'name' => 'testuser/toggle-draft', + 'is_active' => true, + ]); + + $this->mountShowComponent($user, $plugin) + ->call('toggleListing'); + + $plugin->refresh(); + $this->assertTrue($plugin->is_active); + } + + // ======================================== + // Plugin Type & Tier on Submission + // ======================================== + + public function test_submit_free_plugin_does_not_require_tier(): void + { + Notification::fake(); + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + $this->fakeGitHubForSubmission($plugin); + + $this->mountShowComponent($user, $plugin) + ->set('description', 'A test plugin') + ->set('pluginType', 'free') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals(PluginType::Free, $plugin->type); + $this->assertNull($plugin->tier); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + } + + public function test_submit_paid_plugin_requires_tier(): void + { + Feature::define(AllowPaidPlugins::class, true); + + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + + $this->mountShowComponent($user, $plugin) + ->set('description', 'A test plugin') + ->set('pluginType', 'paid') + ->call('save'); + + $plugin->refresh(); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Draft, $plugin->status); + } + + public function test_submit_paid_plugin_with_tier_saves_type_and_tier(): void + { + Notification::fake(); + Feature::define(AllowPaidPlugins::class, true); + + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + $this->fakeGitHubForSubmission($plugin); + + $this->mountShowComponent($user, $plugin) + ->set('description', 'A test plugin') + ->set('pluginType', 'paid') + ->set('tier', 'gold') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals(PluginType::Paid, $plugin->type); + $this->assertEquals(PluginTier::Gold, $plugin->tier); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + } + + public function test_submit_free_plugin_clears_tier(): void + { + Notification::fake(); + $user = $this->createGitHubUser(); + $plugin = $this->createDraftPlugin($user); + $plugin->update(['tier' => PluginTier::Silver]); + $this->fakeGitHubForSubmission($plugin); + + $this->mountShowComponent($user, $plugin) + ->set('description', 'A test plugin') + ->set('pluginType', 'free') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals(PluginType::Free, $plugin->type); + $this->assertNull($plugin->tier); + + $this->mountShowComponent($user, $plugin) + ->call('submitForReview'); + + $plugin->refresh(); + $this->assertEquals(PluginStatus::Pending, $plugin->status); + } +} diff --git a/tests/Feature/PluginShowMobileVersionTest.php b/tests/Feature/PluginShowMobileVersionTest.php index c28408ba..594d12cc 100644 --- a/tests/Feature/PluginShowMobileVersionTest.php +++ b/tests/Feature/PluginShowMobileVersionTest.php @@ -4,6 +4,7 @@ use App\Features\ShowPlugins; use App\Models\Plugin; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Pennant\Feature; use Tests\TestCase; @@ -41,4 +42,53 @@ public function test_plugin_show_displays_dash_when_mobile_min_version_is_null() ->assertStatus(200) ->assertSee('NativePHP Mobile'); } + + public function test_owner_can_preview_draft_plugin_listing(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->draft()->for($user)->create(); + + $this->actingAs($user) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Preview'); + } + + public function test_non_owner_cannot_view_draft_plugin_listing(): void + { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $plugin = Plugin::factory()->draft()->for($owner)->create(); + + $this->actingAs($otherUser) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(404); + } + + public function test_guest_cannot_view_draft_plugin_listing(): void + { + $plugin = Plugin::factory()->draft()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(404); + } + + public function test_delisted_plugin_is_not_visible_to_public(): void + { + $plugin = Plugin::factory()->approved()->create(['is_active' => false]); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(404); + } + + public function test_owner_can_preview_delisted_plugin(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->approved()->for($user)->create(['is_active' => false]); + + $this->actingAs($user) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('de-listed'); + } } diff --git a/tests/Feature/PluginSubmissionNotesTest.php b/tests/Feature/PluginSubmissionNotesTest.php index d01ebfa6..4bd98020 100644 --- a/tests/Feature/PluginSubmissionNotesTest.php +++ b/tests/Feature/PluginSubmissionNotesTest.php @@ -3,12 +3,11 @@ namespace Tests\Feature; use App\Livewire\Customer\Plugins\Create; +use App\Livewire\Customer\Plugins\Show; use App\Models\DeveloperAccount; use App\Models\Plugin; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Notification; use Livewire\Livewire; use Tests\TestCase; @@ -16,49 +15,6 @@ class PluginSubmissionNotesTest extends TestCase { use RefreshDatabase; - /** - * @param array $extraComposerData - */ - private function fakeGitHubForPlugin(string $repoSlug, array $extraComposerData = []): void - { - $base = "https://api.github.com/repos/{$repoSlug}"; - $composerJson = json_encode(array_merge([ - 'name' => $repoSlug, - 'description' => "A test plugin: {$repoSlug}", - 'require' => [ - 'php' => '^8.1', - 'nativephp/mobile' => '^3.0.0', - ], - ], $extraComposerData)); - - Http::fake([ - "{$base}/contents/README.md*" => Http::response([ - 'content' => base64_encode("# {$repoSlug}"), - 'encoding' => 'base64', - ]), - "{$base}/contents/composer.json*" => Http::response([ - 'content' => base64_encode($composerJson), - 'encoding' => 'base64', - ]), - "{$base}/contents/nativephp.json*" => Http::response([], 404), - "{$base}/contents/LICENSE*" => Http::response([], 404), - "{$base}/releases/latest" => Http::response([], 404), - "{$base}/tags*" => Http::response([]), - "{$base}/hooks" => Http::response(['id' => 1]), - "https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404), - $base => Http::response(['default_branch' => 'main']), - "{$base}/git/trees/main*" => Http::response([ - 'tree' => [ - ['path' => 'src/ServiceProvider.php', 'type' => 'blob'], - ], - ]), - "{$base}/readme" => Http::response([ - 'content' => base64_encode("# {$repoSlug}"), - 'encoding' => 'base64', - ]), - ]); - } - private function createUserWithGitHub(): User { $user = User::factory()->create([ @@ -73,149 +29,121 @@ private function createUserWithGitHub(): User } /** @test */ - public function submitting_a_plugin_saves_notes(): void + public function creating_a_plugin_does_not_accept_notes(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/notes-plugin'; - $this->fakeGitHubForPlugin($repoSlug); Livewire::actingAs($user) ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') - ->set('supportChannel', 'help@example.com') - ->set('notes', 'Please review this quickly, we have a launch deadline.') - ->call('submitPlugin') - ->assertRedirect(); - - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); - - $this->assertNotNull($plugin); - $this->assertEquals('Please review this quickly, we have a launch deadline.', $plugin->notes); + ->assertDontSee('Any notes for the review team'); } /** @test */ - public function submitting_a_plugin_without_notes_stores_null(): void + public function creating_a_plugin_does_not_accept_support_channel(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/no-notes-plugin'; - $this->fakeGitHubForPlugin($repoSlug); Livewire::actingAs($user) ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') - ->set('supportChannel', 'help@example.com') - ->call('submitPlugin') - ->assertRedirect(); + ->assertDontSee('Support Channel'); + } - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); + /** @test */ + public function draft_plugin_notes_can_be_updated_via_show_page(): void + { + $user = $this->createUserWithGitHub(); + $plugin = Plugin::factory()->draft()->for($user)->create([ + 'support_channel' => 'support@example.com', + ]); + + [$vendor, $package] = explode('/', $plugin->name); - $this->assertNotNull($plugin); - $this->assertNull($plugin->notes); + Livewire::actingAs($user) + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->set('notes', 'Please review this quickly, we have a launch deadline.') + ->call('save'); + + $plugin->refresh(); + $this->assertEquals('Please review this quickly, we have a launch deadline.', $plugin->notes); } /** @test */ - public function submitting_a_plugin_saves_support_channel_email(): void + public function draft_plugin_support_channel_email_can_be_updated_via_show_page(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/support-email-plugin'; - $this->fakeGitHubForPlugin($repoSlug); + $plugin = Plugin::factory()->draft()->for($user)->create(); + + [$vendor, $package] = explode('/', $plugin->name); Livewire::actingAs($user) - ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) ->set('supportChannel', 'help@example.com') - ->call('submitPlugin') - ->assertRedirect(); + ->call('save'); - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); - - $this->assertNotNull($plugin); + $plugin->refresh(); $this->assertEquals('help@example.com', $plugin->support_channel); } /** @test */ - public function submitting_a_plugin_saves_support_channel_url(): void + public function draft_plugin_support_channel_url_can_be_updated_via_show_page(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/support-url-plugin'; - $this->fakeGitHubForPlugin($repoSlug); + $plugin = Plugin::factory()->draft()->for($user)->create(); + + [$vendor, $package] = explode('/', $plugin->name); Livewire::actingAs($user) - ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) ->set('supportChannel', 'https://example.com/support') - ->call('submitPlugin') - ->assertRedirect(); + ->call('save'); - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); - - $this->assertNotNull($plugin); + $plugin->refresh(); $this->assertEquals('https://example.com/support', $plugin->support_channel); } /** @test */ - public function submitting_a_plugin_without_support_channel_fails_validation(): void + public function draft_plugin_rejects_invalid_support_channel(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/no-support-plugin'; - $this->fakeGitHubForPlugin($repoSlug); + $plugin = Plugin::factory()->draft()->for($user)->create(); - Livewire::actingAs($user) - ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') - ->call('submitPlugin') - ->assertHasErrors(['supportChannel' => 'required']); + [$vendor, $package] = explode('/', $plugin->name); - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); - - $this->assertNull($plugin); + Livewire::actingAs($user) + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->set('supportChannel', 'not-an-email-or-url') + ->call('save') + ->assertHasErrors('supportChannel'); } /** @test */ - public function submitting_a_plugin_with_invalid_support_channel_fails_validation(): void + public function draft_plugin_rejects_empty_support_channel_on_save(): void { - Notification::fake(); $user = $this->createUserWithGitHub(); - $repoSlug = 'acme/invalid-support-plugin'; - $this->fakeGitHubForPlugin($repoSlug); + $plugin = Plugin::factory()->draft()->for($user)->create(['support_channel' => 'old@example.com']); + + [$vendor, $package] = explode('/', $plugin->name); Livewire::actingAs($user) - ->test(Create::class) - ->set('repository', $repoSlug) - ->set('pluginType', 'free') - ->set('supportChannel', 'not-an-email-or-url') - ->call('submitPlugin') + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->set('supportChannel', '') + ->call('save') ->assertHasErrors('supportChannel'); - - $plugin = $user->plugins()->where('repository_url', "https://github.com/{$repoSlug}")->first(); - - $this->assertNull($plugin); } /** @test */ - public function notes_and_support_channel_fields_are_hidden_until_repository_selected(): void + public function draft_plugin_rejects_empty_description_on_save(): void { - $user = User::factory()->create([ - 'github_id' => '12345', - ]); + $user = $this->createUserWithGitHub(); + $plugin = Plugin::factory()->draft()->for($user)->create(['support_channel' => 'support@example.com']); + + [$vendor, $package] = explode('/', $plugin->name); Livewire::actingAs($user) - ->test(Create::class) - ->assertDontSee('Support Channel') - ->assertDontSee('Any notes for the review team') - ->set('repository', 'acme/my-plugin') - ->assertSee('Support Channel') - ->assertSee('Notes'); + ->test(Show::class, ['vendor' => $vendor, 'package' => $package]) + ->set('description', '') + ->call('save') + ->assertHasErrors('description'); } /** @test */ From 0cdb1708f7051b660ed653223267a69c0088994e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 9 Apr 2026 18:35:03 +0100 Subject: [PATCH 2/4] Add read-only type/tier summary for approved plugins and fix duplicate name display Show a non-editable type and pricing tier card on the approved plugin edit page. Only show the composer package name subheading when a custom display name is set, preventing the name from appearing twice. Fix preview banner test assertion to match updated text. Co-Authored-By: Claude Opus 4.6 --- resources/views/cart/show.blade.php | 4 +- resources/views/cart/success.blade.php | 12 +++- .../views/components/plugin-card.blade.php | 4 +- resources/views/customer/team/show.blade.php | 4 +- .../views/customer/ultra/index.blade.php | 4 +- .../customer/developer/dashboard.blade.php | 4 +- .../livewire/customer/plugins/show.blade.php | 56 +++++++++++++------ resources/views/plugin-show.blade.php | 4 +- tests/Feature/AdminPluginPreviewTest.php | 6 +- 9 files changed, 70 insertions(+), 28 deletions(-) diff --git a/resources/views/cart/show.blade.php b/resources/views/cart/show.blade.php index 82fc1920..50883dbf 100644 --- a/resources/views/cart/show.blade.php +++ b/resources/views/cart/show.blade.php @@ -228,7 +228,9 @@ class="flex gap-4 p-6" {{ $item->plugin->display_name ?? $item->plugin->name }} -

    {{ $item->plugin->name }}

    + @if ($item->plugin->display_name) +

    {{ $item->plugin->name }}

    + @endif

    by {{ $item->plugin->user->display_name }}

    diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php index 5874c1a8..d64978c6 100644 --- a/resources/views/cart/success.blade.php +++ b/resources/views/cart/success.blade.php @@ -27,7 +27,9 @@
    {{ $item->plugin->display_name ?? $item->plugin->name }} -

    {{ $item->plugin->name }}

    + @if ($item->plugin->display_name) +

    {{ $item->plugin->name }}

    + @endif @if ($item->plugin->isOfficial() && auth()->user()?->hasUltraAccess())

    Included with Ultra

    @endif @@ -47,7 +49,9 @@
    {{ $plugin->display_name ?? $plugin->name }} -

    {{ $plugin->name }}

    + @if ($plugin->display_name) +

    {{ $plugin->name }}

    + @endif
    @endforeach @@ -213,7 +217,9 @@ class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-gr
    -

    +
    diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index 0da143a8..d811d66e 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -39,7 +39,9 @@ class="size-12 shrink-0 rounded-xl object-cover"

    {{ $plugin->display_name ?? $plugin->name }}

    -

    {{ $plugin->name }}

    + @if ($plugin->display_name) +

    {{ $plugin->name }}

    + @endif @if ($plugin->description)

    {{ $plugin->description }} diff --git a/resources/views/customer/team/show.blade.php b/resources/views/customer/team/show.blade.php index d79e97cc..f6d70085 100644 --- a/resources/views/customer/team/show.blade.php +++ b/resources/views/customer/team/show.blade.php @@ -55,7 +55,9 @@ {{ $plugin->display_name ?? $plugin->name }} - {{ $plugin->name }} + @if ($plugin->display_name) + {{ $plugin->name }} + @endif @if($plugin->description) {{ $plugin->description }} @endif diff --git a/resources/views/customer/ultra/index.blade.php b/resources/views/customer/ultra/index.blade.php index bf787171..b475f467 100644 --- a/resources/views/customer/ultra/index.blade.php +++ b/resources/views/customer/ultra/index.blade.php @@ -153,7 +153,9 @@ {{ $plugin->display_name ?? $plugin->name }} - {{ $plugin->name }} + @if ($plugin->display_name) + {{ $plugin->name }} + @endif @if($plugin->description) {{ $plugin->description }} @endif diff --git a/resources/views/livewire/customer/developer/dashboard.blade.php b/resources/views/livewire/customer/developer/dashboard.blade.php index b78e2ea7..98ceb32b 100644 --- a/resources/views/livewire/customer/developer/dashboard.blade.php +++ b/resources/views/livewire/customer/developer/dashboard.blade.php @@ -69,7 +69,9 @@

    {{ $plugin->display_name ?? $plugin->name }}

    -

    {{ $plugin->name }}

    + @if ($plugin->display_name) +

    {{ $plugin->name }}

    + @endif
    {{ $plugin->licenses_count }} sales diff --git a/resources/views/livewire/customer/plugins/show.blade.php b/resources/views/livewire/customer/plugins/show.blade.php index 08d58af8..0bfb4e77 100644 --- a/resources/views/livewire/customer/plugins/show.blade.php +++ b/resources/views/livewire/customer/plugins/show.blade.php @@ -433,6 +433,30 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file: @elseif ($plugin->isApproved()) {{-- Editable fields for Approved plugins (no tabs) --}} + + {{-- Read-only Type & Tier --}} + +
    +
    + Type + + @if ($plugin->isPaid() && $plugin->tier) + @php + $prices = $plugin->tier->getPrices(); + $subscriberPrice = $prices[\App\Enums\PriceTier::Subscriber->value] / 100; + $regularPrice = $prices[\App\Enums\PriceTier::Regular->value] / 100; + @endphp + Paid — {{ $plugin->tier->label() }} (${{ number_format($subscriberPrice) }} – ${{ number_format($regularPrice) }}) + @elseif ($plugin->isPaid()) + Paid + @else + Free + @endif + +
    +
    +
    +
    {{-- Display Name --}} @@ -449,22 +473,6 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
    - {{-- Support Channel --}} - - Support - How can users get support for your plugin? Provide an email address or a URL. - -
    - - @error('supportChannel') - {{ $message }} - @enderror -
    -
    - {{-- Description --}} Description @@ -574,6 +582,22 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
    + {{-- Support Channel --}} + + Support + How can users get support for your plugin? Provide an email address or a URL. + +
    + + @error('supportChannel') + {{ $message }} + @enderror +
    +
    + {{-- Save Button --}}
    Save Changes diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 03ec8de0..efabe627 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -87,7 +87,9 @@ class="text-2xl font-bold sm:text-3xl" > {{ $plugin->display_name ?? $plugin->name }} -

    {{ $plugin->name }}

    + @if ($plugin->display_name) +

    {{ $plugin->name }}

    + @endif @if ($plugin->description)

    {{ $plugin->description }} diff --git a/tests/Feature/AdminPluginPreviewTest.php b/tests/Feature/AdminPluginPreviewTest.php index 40f5d2a9..981253ee 100644 --- a/tests/Feature/AdminPluginPreviewTest.php +++ b/tests/Feature/AdminPluginPreviewTest.php @@ -59,7 +59,7 @@ public function test_admin_sees_preview_banner_on_pending_plugin(): void $this->actingAs($admin) ->get(route('plugins.show', $plugin->routeParams())) - ->assertSee('Admin Preview') + ->assertSee('Preview') ->assertSee('Pending Review'); } @@ -68,7 +68,7 @@ public function test_approved_plugin_does_not_show_preview_banner(): void $plugin = Plugin::factory()->approved()->create(); $this->get(route('plugins.show', $plugin->routeParams())) - ->assertDontSee('Admin Preview'); + ->assertDontSee('This plugin is not publicly visible'); } public function test_admin_can_view_approved_plugin_without_preview_banner(): void @@ -81,6 +81,6 @@ public function test_admin_can_view_approved_plugin_without_preview_banner(): vo $this->actingAs($admin) ->get(route('plugins.show', $plugin->routeParams())) ->assertStatus(200) - ->assertDontSee('Admin Preview'); + ->assertDontSee('This plugin is not publicly visible'); } } From 2f99e6a753a536bd4be0b30049c62ec72ec39ea1 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 9 Apr 2026 18:54:35 +0100 Subject: [PATCH 3/4] Improve plugin edit UX: tier validation, de-listed indicator, submit summary Add server-side validation for pricing tier when saving paid draft plugins with inline error and red card border. Show gray dot for de-listed plugins and remove Status column from plugin index. Move GitHub banner into Details tab. Add plugin summary card to Submit for Review tab. Co-Authored-By: Claude Opus 4.6 --- app/Livewire/Customer/Plugins/Show.php | 9 +- .../livewire/customer/plugins/index.blade.php | 9 +- .../livewire/customer/plugins/show.blade.php | 114 +++++++++++++++--- .../Customer/PluginStatusTransitionsTest.php | 13 +- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 83ee313a..b10163b8 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -170,9 +170,16 @@ function (string $attribute, mixed $value, \Closure $fail) { if ($this->plugin->isDraft()) { $rules['notes'] = ['nullable', 'string', 'max:5000']; + $rules['pluginType'] = ['required', 'string', 'in:free,paid']; + + if ($this->pluginType === 'paid') { + $rules['tier'] = ['required', 'string', 'in:bronze,silver,gold']; + } } - $this->validate($rules); + $this->validate($rules, [ + 'tier.required' => 'Please select a pricing tier for your paid plugin.', + ]); $data = [ 'display_name' => $this->displayName ?: null, diff --git a/resources/views/livewire/customer/plugins/index.blade.php b/resources/views/livewire/customer/plugins/index.blade.php index 1ce72295..9edc925c 100644 --- a/resources/views/livewire/customer/plugins/index.blade.php +++ b/resources/views/livewire/customer/plugins/index.blade.php @@ -77,7 +77,6 @@ Plugin - Status @@ -91,8 +90,10 @@

    @elseif ($plugin->isPending())
    - @elseif ($plugin->isApproved()) + @elseif ($plugin->isApproved() && $plugin->is_active)
    + @elseif ($plugin->isApproved() && ! $plugin->is_active) +
    @else
    @endif @@ -109,10 +110,6 @@
    - - - - @if ($plugin->isDraft()) diff --git a/resources/views/livewire/customer/plugins/show.blade.php b/resources/views/livewire/customer/plugins/show.blade.php index 0bfb4e77..13a52106 100644 --- a/resources/views/livewire/customer/plugins/show.blade.php +++ b/resources/views/livewire/customer/plugins/show.blade.php @@ -78,21 +78,6 @@ @endif - {{-- Plugin Status (hidden for Pending/Rejected — details card covers this) --}} - @if ($plugin->isDraft() || $plugin->isApproved()) - - -
    -
    - - {{ $plugin->name }} -
    - -
    -
    -
    - @endif - {{-- Review Checks (show for Pending, Rejected, Approved — not Draft) --}} @if (! $plugin->isDraft() && $plugin->review_checks) @@ -202,6 +187,19 @@ + {{-- GitHub Repo --}} + + +
    +
    + + {{ $plugin->name }} +
    + +
    +
    +
    + {{-- Plugin Type --}} @feature(App\Features\AllowPaidPlugins::class) @@ -232,7 +230,7 @@ {{-- Pricing Tier (only when paid) --}} @if ($pluginType === 'paid') - + Pricing Tier Choose a pricing tier for your plugin. @@ -254,6 +252,10 @@ @endforeach
    + @error('tier') + {{ $message }} + @enderror + Actual sale price may vary due to discounts and offers. You keep 70% of the sale price. If a NativePHP Ultra subscriber purchases your plugin, you receive 100% of the sale price. Additional payment processing fees may apply. @@ -410,6 +412,73 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
    + {{-- Plugin Summary --}} + +
    +
    + @if ($plugin->hasLogo()) + {{ $plugin->name }} logo + @elseif ($plugin->hasGradientIcon()) +
    + +
    + @else +
    + +
    + @endif +
    + {{ $plugin->display_name ?? $plugin->name }} + @if ($plugin->display_name) + {{ $plugin->name }} + @endif + @if ($plugin->description) + {{ $plugin->description }} + @else + No description provided + @endif +
    +
    + @if ($plugin->isPaid() && $plugin->tier) + @php + $regularPrice = $plugin->tier->getPrices()[\App\Enums\PriceTier::Regular->value] / 100; + @endphp + + {{ $plugin->tier->label() }} — ${{ number_format($regularPrice) }} + + @elseif ($plugin->isPaid()) + + Paid + + @else + + Free + + @endif +
    + + + +
    +
    + Support Channel + @if ($plugin->support_channel) + {{ $plugin->support_channel }} + @else + No support channel set + @endif +
    + + +
    +
    + {{-- Notes --}} Notes @@ -434,6 +503,19 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file: @elseif ($plugin->isApproved()) {{-- Editable fields for Approved plugins (no tabs) --}} + {{-- GitHub Repo --}} + + +
    +
    + + {{ $plugin->name }} +
    + +
    +
    +
    + {{-- Read-only Type & Tier --}}
    diff --git a/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php index 5e96ae48..ccf74e6d 100644 --- a/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php +++ b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php @@ -369,7 +369,7 @@ public function test_submit_free_plugin_does_not_require_tier(): void $this->assertEquals(PluginStatus::Pending, $plugin->status); } - public function test_submit_paid_plugin_requires_tier(): void + public function test_save_paid_plugin_requires_tier(): void { Feature::define(AllowPaidPlugins::class, true); @@ -378,16 +378,13 @@ public function test_submit_paid_plugin_requires_tier(): void $this->mountShowComponent($user, $plugin) ->set('description', 'A test plugin') + ->set('supportChannel', 'support@example.com') ->set('pluginType', 'paid') - ->call('save'); - - $plugin->refresh(); - - $this->mountShowComponent($user, $plugin) - ->call('submitForReview'); + ->call('save') + ->assertHasErrors(['tier']); $plugin->refresh(); - $this->assertEquals(PluginStatus::Draft, $plugin->status); + $this->assertNull($plugin->tier); } public function test_submit_paid_plugin_with_tier_saves_type_and_tier(): void From 94c0f653090c42cf474688c0535d57262fcb91ae Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 10 Apr 2026 00:55:13 +0100 Subject: [PATCH 4/4] Add command to send plugin submission reminder notifications New artisan command `plugins:send-submission-reminders` emails and notifies users with unapproved plugins to finalize their submissions, listing each plugin by Composer package name. Supports --dry-run flag. Co-Authored-By: Claude Opus 4.6 --- .../SendPluginSubmissionReminders.php | 57 +++++++ .../PluginSubmissionReminder.php | 59 +++++++ .../SendPluginSubmissionRemindersTest.php | 154 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 app/Console/Commands/SendPluginSubmissionReminders.php create mode 100644 app/Notifications/PluginSubmissionReminder.php create mode 100644 tests/Feature/SendPluginSubmissionRemindersTest.php diff --git a/app/Console/Commands/SendPluginSubmissionReminders.php b/app/Console/Commands/SendPluginSubmissionReminders.php new file mode 100644 index 00000000..1c80d3ee --- /dev/null +++ b/app/Console/Commands/SendPluginSubmissionReminders.php @@ -0,0 +1,57 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('DRY RUN - No notifications will be sent'); + } + + $users = User::query() + ->whereHas('plugins', function ($query) { + $query->whereIn('status', [PluginStatus::Draft, PluginStatus::Pending, PluginStatus::Rejected]); + }) + ->with(['plugins' => function ($query) { + $query->whereIn('status', [PluginStatus::Draft, PluginStatus::Pending, PluginStatus::Rejected]) + ->orderBy('name'); + }]) + ->get(); + + $this->info("Found {$users->count()} user(s) with unapproved plugins"); + + $sent = 0; + + foreach ($users as $user) { + $pluginNames = $user->plugins->pluck('name')->join(', '); + + if ($dryRun) { + $this->line("Would send to: {$user->email} ({$user->plugins->count()} plugin(s): {$pluginNames})"); + } else { + $user->notify(new PluginSubmissionReminder($user->plugins)); + $this->line("Sent to: {$user->email} ({$user->plugins->count()} plugin(s): {$pluginNames})"); + } + + $sent++; + } + + $this->newLine(); + $this->info($dryRun ? "Would send: {$sent} notification(s)" : "Sent: {$sent} notification(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Notifications/PluginSubmissionReminder.php b/app/Notifications/PluginSubmissionReminder.php new file mode 100644 index 00000000..d8dd7fec --- /dev/null +++ b/app/Notifications/PluginSubmissionReminder.php @@ -0,0 +1,59 @@ + $plugins + */ + public function __construct(public Collection $plugins) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject('Action Required: Finalize Your Plugin Submission') + ->greeting("Hi {$notifiable->name},") + ->line('We\'ve recently updated the plugin submission process with new requirements. Please review your pending plugin submissions to ensure they are configured correctly — particularly whether you intended to submit a **free** or **paid** plugin.') + ->line('The following plugins need your attention:'); + + foreach ($this->plugins as $plugin) { + $message->line("- **{$plugin->name}** ({$plugin->status->label()})"); + } + + $message->action('Review Your Plugins', route('customer.plugins.index')) + ->line('Please visit your plugin dashboard, review each submission, and re-submit when ready.') + ->salutation("Thanks,\n\nThe NativePHP Team"); + + return $message; + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Action Required: Finalize Your Plugin Submissions', + 'body' => 'Please review your pending plugin submissions to ensure they are configured correctly.', + 'plugin_names' => $this->plugins->pluck('name')->all(), + ]; + } +} diff --git a/tests/Feature/SendPluginSubmissionRemindersTest.php b/tests/Feature/SendPluginSubmissionRemindersTest.php new file mode 100644 index 00000000..5ad1ed19 --- /dev/null +++ b/tests/Feature/SendPluginSubmissionRemindersTest.php @@ -0,0 +1,154 @@ +create(); + Plugin::factory()->draft()->for($user)->create(); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentTo($user, PluginSubmissionReminder::class); + } + + public function test_sends_notification_to_user_with_pending_plugin(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->pending()->for($user)->create(); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentTo($user, PluginSubmissionReminder::class); + } + + public function test_sends_notification_to_user_with_rejected_plugin(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->rejected()->for($user)->create(); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentTo($user, PluginSubmissionReminder::class); + } + + public function test_does_not_send_to_user_with_only_approved_plugins(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->approved()->for($user)->create(); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertNothingSent(); + } + + public function test_sends_one_notification_per_user_with_multiple_plugins(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->draft()->for($user)->create(['name' => 'acme/plugin-one']); + Plugin::factory()->pending()->for($user)->create(['name' => 'acme/plugin-two']); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentToTimes($user, PluginSubmissionReminder::class, 1); + + Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) { + return $notification->plugins->count() === 2; + }); + } + + public function test_notification_includes_plugin_names(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->draft()->for($user)->create(['name' => 'acme/my-plugin']); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) { + return $notification->plugins->first()->name === 'acme/my-plugin'; + }); + } + + public function test_excludes_approved_plugins_from_notification(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->draft()->for($user)->create(['name' => 'acme/draft-one']); + Plugin::factory()->approved()->for($user)->create(['name' => 'acme/approved-one']); + + $this->artisan('plugins:send-submission-reminders') + ->assertExitCode(0); + + Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) { + return $notification->plugins->count() === 1 + && $notification->plugins->first()->name === 'acme/draft-one'; + }); + } + + public function test_dry_run_does_not_send_notifications(): void + { + Notification::fake(); + + $user = User::factory()->create(); + Plugin::factory()->draft()->for($user)->create(); + + $this->artisan('plugins:send-submission-reminders', ['--dry-run' => true]) + ->assertExitCode(0); + + Notification::assertNothingSent(); + } + + public function test_notification_email_contains_expected_content(): void + { + $user = User::factory()->create(['name' => 'Jane']); + $plugin = Plugin::factory()->draft()->for($user)->create(['name' => 'acme/test-plugin']); + + $notification = new PluginSubmissionReminder(collect([$plugin])); + $mail = $notification->toMail($user); + + $this->assertStringContainsString('Action Required', $mail->subject); + $this->assertStringContainsString('acme/test-plugin', implode(' ', array_map(fn ($line) => (string) $line, $mail->introLines))); + } + + public function test_notification_database_array_contains_plugin_names(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->draft()->for($user)->create(['name' => 'acme/test-plugin']); + + $notification = new PluginSubmissionReminder(collect([$plugin])); + $data = $notification->toArray($user); + + $this->assertArrayHasKey('plugin_names', $data); + $this->assertContains('acme/test-plugin', $data['plugin_names']); + } +}