diff --git a/app/Jobs/ReviewPluginRepository.php b/app/Jobs/ReviewPluginRepository.php
index c11383d4..deb4facf 100644
--- a/app/Jobs/ReviewPluginRepository.php
+++ b/app/Jobs/ReviewPluginRepository.php
@@ -25,12 +25,29 @@ public function handle(): array
{
$repo = $this->plugin->getRepositoryOwnerAndName();
+ $failedChecks = [
+ 'has_license_file' => false,
+ 'has_release_version' => false,
+ 'release_version' => null,
+ 'supports_ios' => false,
+ 'supports_android' => false,
+ 'supports_js' => false,
+ 'requires_mobile_sdk' => false,
+ 'mobile_sdk_constraint' => null,
+ 'has_ios_min_version' => false,
+ 'ios_min_version' => null,
+ 'has_android_min_version' => false,
+ 'android_min_version' => null,
+ ];
+
if (! $repo) {
Log::warning('[ReviewPluginRepository] No valid repository URL', [
'plugin_id' => $this->plugin->id,
]);
- return [];
+ $this->plugin->update(['review_checks' => $failedChecks, 'reviewed_at' => now()]);
+
+ return $failedChecks;
}
$token = $this->getGitHubToken();
@@ -44,7 +61,9 @@ public function handle(): array
'plugin_id' => $this->plugin->id,
]);
- return [];
+ $this->plugin->update(['review_checks' => $failedChecks, 'reviewed_at' => now()]);
+
+ return $failedChecks;
}
$tree = $this->fetchRepoTree($owner, $repoName, $defaultBranch, $token);
diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php
index cd614843..b6ccc56e 100644
--- a/app/Livewire/Customer/Plugins/Show.php
+++ b/app/Livewire/Customer/Plugins/Show.php
@@ -8,7 +8,9 @@
use App\Models\Plugin;
use App\Notifications\PluginSubmitted;
use App\Services\GitHubUserService;
+use Flux\Flux;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@@ -74,45 +76,86 @@ public function mount(string $vendor, string $package): void
$this->tier = $this->plugin->tier?->value;
}
+ public function runPreflightChecks(): void
+ {
+ if (! $this->plugin->isDraft()) {
+ return;
+ }
+
+ $user = auth()->user();
+ $repoInfo = $this->plugin->getRepositoryOwnerAndName();
+
+ // Ensure a webhook secret exists so we can always show setup instructions
+ if (! $this->plugin->webhook_secret) {
+ $this->plugin->generateWebhookSecret();
+ }
+
+ // Verify or install webhook
+ if ($repoInfo && $user->hasGitHubToken()) {
+ $githubService = GitHubUserService::for($user);
+ $webhookUrl = $this->plugin->getWebhookUrl();
+
+ // Check if our webhook already exists on the repo
+ if ($webhookUrl && $githubService->webhookExists($repoInfo['owner'], $repoInfo['repo'], $webhookUrl)) {
+ if (! $this->plugin->webhook_installed) {
+ $this->plugin->update(['webhook_installed' => true]);
+ }
+ } else {
+ // Webhook not found on GitHub — try to create it
+ $webhookSecret = $this->plugin->webhook_secret ?? $this->plugin->generateWebhookSecret();
+ $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();
+
+ $this->plugin->refresh();
+ }
+
public function submitForReview(): void
{
if (! $this->plugin->isDraft()) {
- session()->flash('error', 'Only draft plugins can be submitted for review.');
+ Flux::toast(variant: 'danger', text: 'Only draft plugins can be submitted for review.');
+
+ return;
+ }
+
+ if (! $this->plugin->description) {
+ Flux::toast(variant: 'danger', text: 'Please add a description before submitting for review.');
return;
}
if (! $this->plugin->support_channel) {
- session()->flash('error', 'Please set a support channel before submitting for review.');
+ Flux::toast(variant: 'danger', text: '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.');
+ Flux::toast(variant: 'danger', text: 'Please select a pricing tier for your paid plugin.');
return;
}
- $user = auth()->user();
+ // Run preflight checks
+ $this->runPreflightChecks();
- // Install webhook
- $repoInfo = $this->plugin->getRepositoryOwnerAndName();
+ // Only submit if required checks pass
+ if (! $this->plugin->passesRequiredReviewChecks()) {
+ Flux::toast(variant: 'danger', text: 'Your plugin doesn\'t pass all required checks yet. Please resolve the failing checks and try again.');
- 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']]);
+ return;
}
- // Run review checks
- (new ReviewPluginRepository($this->plugin))->handle();
+ $user = auth()->user();
// Submit
$this->plugin->submit();
@@ -121,13 +164,61 @@ public function submitForReview(): void
// Notify
$user->notify(new PluginSubmitted($this->plugin));
- session()->flash('success', 'Your plugin has been submitted for review!');
+ Flux::toast(variant: 'success', text: 'Your plugin has been submitted for review!');
+ }
+
+ public function certifyWebhook(): void
+ {
+ if ($this->plugin->webhook_installed) {
+ return;
+ }
+
+ if (! $this->plugin->webhook_secret) {
+ $this->plugin->generateWebhookSecret();
+ }
+
+ $this->plugin->update(['webhook_installed' => true]);
+ $this->plugin->refresh();
+
+ $this->modal('certify-webhook')->close();
+
+ Flux::toast(variant: 'success', text: 'Webhook marked as installed.');
+ }
+
+ public function retryWebhook(): void
+ {
+ $user = auth()->user();
+ $repoInfo = $this->plugin->getRepositoryOwnerAndName();
+
+ if (! $repoInfo || ! $user->hasGitHubToken()) {
+ Flux::toast(variant: 'danger', text: 'Unable to register webhook automatically. Please ensure your GitHub account is connected and the repository URL is valid.');
+
+ return;
+ }
+
+ $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']]);
+ $this->plugin->refresh();
+
+ if ($webhookResult['success']) {
+ Flux::toast(variant: 'success', text: 'Webhook installed successfully.');
+ } else {
+ Flux::toast(variant: 'danger', text: 'Failed to install webhook: '.($webhookResult['error'] ?? 'Unknown error'));
+ }
}
public function withdrawFromReview(): void
{
if (! $this->plugin->isPending()) {
- session()->flash('error', 'Only pending plugins can be withdrawn.');
+ Flux::toast(variant: 'danger', text: 'Only pending plugins can be withdrawn.');
return;
}
@@ -135,13 +226,13 @@ public function withdrawFromReview(): void
$this->plugin->withdraw();
$this->plugin->refresh();
- session()->flash('success', 'Your plugin has been withdrawn from review and returned to draft.');
+ Flux::toast(variant: 'success', text: '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.');
+ Flux::toast(variant: 'danger', text: 'Only rejected plugins can be returned to draft.');
return;
}
@@ -149,13 +240,13 @@ public function returnToDraft(): void
$this->plugin->returnToDraft();
$this->plugin->refresh();
- session()->flash('success', 'Your plugin has been returned to draft. You can make changes and resubmit.');
+ Flux::toast(variant: 'success', text: '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.');
+ Flux::toast(variant: 'danger', text: 'You can only edit draft or approved plugins.');
return;
}
@@ -189,7 +280,7 @@ function (string $attribute, mixed $value, \Closure $fail) {
]);
if ($this->plugin->isDraft() && $this->pluginType === 'paid' && ! $this->hasCompletedDeveloperOnboarding) {
- session()->flash('error', 'You must complete developer onboarding before setting a plugin as paid.');
+ Flux::toast(variant: 'danger', text: 'You must complete developer onboarding before setting a plugin as paid.');
return;
}
@@ -211,13 +302,13 @@ function (string $attribute, mixed $value, \Closure $fail) {
$this->plugin->updateDescription($this->description, auth()->id());
$this->plugin->refresh();
- session()->flash('success', 'Plugin details saved successfully!');
+ Flux::toast(variant: 'success', text: '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.');
+ Flux::toast(variant: 'danger', text: 'You can only edit the icon for draft or approved plugins.');
return;
}
@@ -239,13 +330,13 @@ public function updateIcon(): void
$this->plugin->refresh();
- session()->flash('success', 'Plugin icon updated successfully!');
+ Flux::toast(variant: 'success', text: 'Plugin icon updated successfully!');
}
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.');
+ Flux::toast(variant: 'danger', text: 'You can only upload a logo for draft or approved plugins.');
return;
}
@@ -270,13 +361,13 @@ public function uploadLogo(): void
$this->logo = null;
$this->iconMode = 'upload';
- session()->flash('success', 'Plugin logo updated successfully!');
+ Flux::toast(variant: 'success', text: 'Plugin logo updated successfully!');
}
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.');
+ Flux::toast(variant: 'danger', text: 'You can only remove the icon for draft or approved plugins.');
return;
}
@@ -294,13 +385,13 @@ public function deleteIcon(): void
$this->plugin->refresh();
$this->iconMode = 'gradient';
- session()->flash('success', 'Plugin icon removed successfully!');
+ Flux::toast(variant: 'success', text: 'Plugin icon removed successfully!');
}
public function toggleListing(): void
{
if (! $this->plugin->isApproved()) {
- session()->flash('error', 'Only approved plugins can be listed or de-listed.');
+ Flux::toast(variant: 'danger', text: 'Only approved plugins can be listed or de-listed.');
return;
}
@@ -312,7 +403,18 @@ public function toggleListing(): void
$this->plugin->refresh();
$action = $this->plugin->is_active ? 'listed' : 'de-listed';
- session()->flash('success', "Your plugin has been {$action}.");
+ Flux::toast(variant: 'success', text: "Your plugin has been {$action}.");
+ }
+
+ public function validate($rules = [], $messages = [], $attributes = []): array
+ {
+ try {
+ return parent::validate($rules, $messages, $attributes);
+ } catch (ValidationException $e) {
+ $this->dispatch('scroll-to-first-error');
+
+ throw $e;
+ }
}
public function render()
diff --git a/app/Services/GitHubUserService.php b/app/Services/GitHubUserService.php
index a1ca53e3..29099bd9 100644
--- a/app/Services/GitHubUserService.php
+++ b/app/Services/GitHubUserService.php
@@ -144,6 +144,33 @@ public function getComposerJson(string $owner, string $repo, string $branch = 'm
return json_decode($content, true);
}
+ /**
+ * Check if a webhook with the given URL exists on a GitHub repository.
+ */
+ public function webhookExists(string $owner, string $repo, string $webhookUrl): bool
+ {
+ $token = $this->user->getGitHubToken();
+
+ if (! $token) {
+ return false;
+ }
+
+ $response = Http::withToken($token)
+ ->get("https://api.github.com/repos/{$owner}/{$repo}/hooks");
+
+ if ($response->failed()) {
+ return false;
+ }
+
+ foreach ($response->json() as $hook) {
+ if (($hook['config']['url'] ?? null) === $webhookUrl) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Create a webhook on a GitHub repository.
*
diff --git a/package-lock.json b/package-lock.json
index 2f4dfbb4..c145fd92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "cosmic-wasp",
+ "name": "agile-sloth",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php
index ccf29c31..187e0b45 100644
--- a/resources/views/components/layouts/dashboard.blade.php
+++ b/resources/views/components/layouts/dashboard.blade.php
@@ -250,6 +250,8 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
{{ $slot }}
+
+
@livewireScriptConfig
diff --git a/resources/views/livewire/customer/plugins/show.blade.php b/resources/views/livewire/customer/plugins/show.blade.php
index f1313d2c..b0b234c9 100644
--- a/resources/views/livewire/customer/plugins/show.blade.php
+++ b/resources/views/livewire/customer/plugins/show.blade.php
@@ -25,19 +25,6 @@
- {{-- Success/Error Messages --}}
- @if (session('success'))
-
- {{ session('success') }}
-
- @endif
-
- @if (session('error'))
-
- {{ session('error') }}
-
- @endif
-
{{-- Status-specific banners --}}
@if ($plugin->isDraft())
@@ -49,9 +36,30 @@
Under Review
Your plugin is currently being reviewed. You can withdraw it to make changes.
- Withdraw from Review
+
+ Withdraw from Review
+
+
+
+
+
+ Withdraw from Review
+
+ Are you sure you want to withdraw this plugin from review? It will return to draft status.
+
+
+
+
+
+
+ Cancel
+
+ Withdraw
+
+
+
@elseif ($plugin->isRejected() && $plugin->rejection_reason)
Rejection Reason
@@ -78,8 +86,8 @@
@endif
- {{-- Review Checks (show for Pending, Rejected, Approved — not Draft) --}}
- @if (! $plugin->isDraft() && $plugin->review_checks)
+ {{-- Review Checks (show when review checks have been run) --}}
+ @if ($plugin->review_checks)
Review Checks
Automated checks run against your repository.
@@ -122,29 +130,68 @@
@endif
- @if ($check['key'] === 'webhook_configured' && ! $isPassing && $plugin->webhook_secret)
-
-
- We couldn't automatically install the webhook. Please set it up manually:
-
-
-
Webhook URL
-
-
{{ $plugin->getWebhookUrl() }}
-
-
+ @if ($check['key'] === 'webhook_configured')
+ @if (! $isPassing)
+
+
+ We couldn't automatically install the webhook. Please set it up manually:
+
+
+
Webhook URL
+
+ {{ $plugin->getWebhookUrl() }}
+
+
+
+
+
+
+ Go to your repository's Settings → Webhooks
+ Click Add webhook
+ Paste the URL above into the Payload URL field
+ Set Content type to application/json
+ Select events: Pushes and Releases
+ Click Add webhook
+
+
+
+
+ Retry automatic setup
+
+
+ I've installed it manually
+
+
-
- Go to your repository's Settings → Webhooks
- Click Add webhook
- Paste the URL above into the Payload URL field
- Set Content type to application/json
- Select events: Pushes and Releases
- Click Add webhook
-
-
+ @elseif ($plugin->webhook_secret)
+
+
+
+ View setup instructions
+
+
+
+
Webhook URL
+
+ {{ $plugin->getWebhookUrl() }}
+
+
+
+
+
+
+ Go to your repository's Settings → Webhooks
+ Click Add webhook
+ Paste the URL above into the Payload URL field
+ Set Content type to application/json
+ Select events: Pushes and Releases
+ Click Add webhook
+
+
+
+ @endif
@endif
@endforeach
@@ -175,6 +222,31 @@
Last checked {{ $plugin->reviewed_at->diffForHumans() }}
@endif
+
+
+
+
+ Confirm Webhook Installation
+
+ Please confirm that you have manually installed the webhook on your GitHub repository.
+
+
+
+
+
+ If the webhook is not correctly installed, we won't be able to sync your plugin's releases and metadata automatically.
+
+
+
+
+
+
+ Cancel
+
+ Confirm
+
+
+
@endif
@@ -459,30 +531,110 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
-
+
+ {{-- Author --}}
+
+
+ {{-- Version --}}
+ {{-- License --}}
-
+
+ {{-- iOS Version --}}
+
+
Min iOS
+
+ {{ $plugin->ios_version ?? ($plugin->review_checks['ios_min_version'] ?? '—') }}
+
+
+
+ {{-- Android Version --}}
+
+
Min Android
+
+ {{ $plugin->android_version ?? ($plugin->review_checks['android_min_version'] ?? '—') }}
+
+
+
+ {{-- Support Channel --}}
+
+
+ {{-- Repository --}}
+
+
{{-- Notes --}}
Notes
- Any notes for the review team? These won't be displayed on your plugin listing.
+ Share links to videos demonstrating the plugin in use or applications already available on the app stores using this plugin. Provide as much extra context for the review team so they can review your plugin more easily. These notes will not be displayed on your plugin listing.
- Submit for Review
+ {{-- Preflight Checks & Submit --}}
+
+ @if ($plugin->review_checks)
+ Re-run Checks
+ @endif
+ {{ $plugin->review_checks ? 'Submit for Review' : 'Submit' }}
@@ -722,24 +877,101 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
-
+
+ {{-- Author --}}
+
+
Author
+
+ {{ $plugin->user->display_name }}
+
+
+
+ {{-- Version --}}
+ {{-- License --}}
-
+
+ {{-- iOS Version --}}
+
+
Min iOS
+
+ {{ $plugin->ios_version ?? ($plugin->review_checks['ios_min_version'] ?? '—') }}
+
+
+
+ {{-- Android Version --}}
+
+
Min Android
+
+ {{ $plugin->android_version ?? ($plugin->review_checks['android_min_version'] ?? '—') }}
+
+
+
+ {{-- Support Channel --}}
+
+
+ {{-- Repository --}}
+
+
@if ($plugin->notes)
@@ -751,4 +983,17 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
@endif
+
+ @script
+
+ @endscript
diff --git a/tests/Feature/CustomerPluginReviewChecksTest.php b/tests/Feature/CustomerPluginReviewChecksTest.php
index 7d23f360..6ef83f55 100644
--- a/tests/Feature/CustomerPluginReviewChecksTest.php
+++ b/tests/Feature/CustomerPluginReviewChecksTest.php
@@ -46,7 +46,13 @@ private function fakeGitHubForCreateAndSubmit(string $repoSlug): void
"https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404),
// Webhook creation
- "{$base}/hooks" => Http::response(['id' => 1], 201),
+ "{$base}/hooks" => function ($request) {
+ if ($request->method() === 'GET') {
+ return Http::response([], 200);
+ }
+
+ return Http::response(['id' => 1], 201);
+ },
// ReviewPluginRepository calls
$base => Http::response(['default_branch' => 'main']),
@@ -126,7 +132,7 @@ public function submitting_a_plugin_for_review_runs_review_checks(): void
}
/** @test */
- public function plugin_submitted_email_includes_failing_checks(): void
+ public function plugin_submitted_email_includes_failing_optional_checks(): void
{
Notification::fake();
@@ -157,14 +163,21 @@ public function plugin_submitted_email_includes_failing_checks(): void
]),
"{$base}/contents/nativephp.json" => Http::response([], 404),
"{$base}/contents/LICENSE*" => Http::response([], 404),
- "{$base}/releases/latest" => Http::response([], 404),
- "{$base}/tags*" => Http::response([]),
+ "{$base}/releases/latest" => Http::response(['tag_name' => 'v1.0.0']),
+ "{$base}/tags*" => Http::response([['name' => 'v1.0.0']]),
"https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404),
- "{$base}/hooks" => Http::response([], 422),
+ "{$base}/hooks" => function ($request) {
+ if ($request->method() === 'GET') {
+ return Http::response([], 200);
+ }
+
+ return Http::response(['id' => 1], 201);
+ },
$base => Http::response(['default_branch' => 'main']),
"{$base}/git/trees/main*" => Http::response([
'tree' => [
['path' => 'src/ServiceProvider.php', 'type' => 'blob'],
+ ['path' => 'LICENSE', 'type' => 'blob'],
],
]),
"{$base}/readme" => Http::response([
@@ -197,13 +210,20 @@ public function plugin_submitted_email_includes_failing_checks(): void
]),
"{$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}/releases/latest" => Http::response(['tag_name' => 'v1.0.0']),
+ "{$base}/tags*" => Http::response([['name' => 'v1.0.0']]),
+ "{$base}/hooks" => function ($request) {
+ if ($request->method() === 'GET') {
+ return Http::response([], 200);
+ }
+
+ return Http::response(['id' => 1], 201);
+ },
$base => Http::response(['default_branch' => 'main']),
"{$base}/git/trees/main*" => Http::response([
'tree' => [
['path' => 'src/ServiceProvider.php', 'type' => 'blob'],
+ ['path' => 'LICENSE', 'type' => 'blob'],
],
]),
"{$base}/readme" => Http::response([
@@ -213,7 +233,7 @@ public function plugin_submitted_email_includes_failing_checks(): void
"https://raw.githubusercontent.com/{$repoSlug}/*" => Http::response('', 404),
]);
- // Step 2: Submit for review
+ // Step 2: Submit for review (required checks pass, optional ones don't)
[$vendor, $package] = explode('/', $plugin->name);
Livewire::actingAs($user)
->test(Show::class, ['vendor' => $vendor, 'package' => $package])
@@ -221,15 +241,12 @@ public function plugin_submitted_email_includes_failing_checks(): void
$plugin->refresh();
+ $this->assertEquals('pending', $plugin->status->value);
+
Notification::assertSentTo($user, PluginSubmitted::class, function (PluginSubmitted $notification) use ($plugin) {
$mail = $notification->toMail($plugin->user);
$rendered = $mail->render()->toHtml();
- // Should mention failing required checks
- $this->assertStringContainsString('LICENSE', $rendered);
- $this->assertStringContainsString('release version', $rendered);
- $this->assertStringContainsString('webhook', $rendered);
-
// Should mention failing optional checks
$this->assertStringContainsString('Add iOS support', $rendered);
$this->assertStringContainsString('Add Android support', $rendered);
diff --git a/tests/Feature/Jobs/ReviewPluginRepositoryTest.php b/tests/Feature/Jobs/ReviewPluginRepositoryTest.php
index d15dc9e4..ca7499e3 100644
--- a/tests/Feature/Jobs/ReviewPluginRepositoryTest.php
+++ b/tests/Feature/Jobs/ReviewPluginRepositoryTest.php
@@ -221,7 +221,7 @@ public function it_stores_results_in_review_checks_and_stamps_reviewed_at(): voi
}
/** @test */
- public function it_returns_empty_array_when_no_repository_url(): void
+ public function it_returns_failed_checks_when_no_repository_url(): void
{
$plugin = Plugin::factory()->create([
'repository_url' => null,
@@ -229,8 +229,10 @@ public function it_returns_empty_array_when_no_repository_url(): void
$checks = (new ReviewPluginRepository($plugin))->handle();
- $this->assertEmpty($checks);
- $this->assertNull($plugin->fresh()->reviewed_at);
+ $this->assertFalse($checks['has_license_file']);
+ $this->assertFalse($checks['has_release_version']);
+ $this->assertNotNull($plugin->fresh()->reviewed_at);
+ $this->assertNotNull($plugin->fresh()->review_checks);
}
/** @test */
diff --git a/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php
index 1141a406..2abbd9ce 100644
--- a/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php
+++ b/tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php
@@ -42,26 +42,43 @@ private function createDraftPlugin(User $user, ?string $supportChannel = 'suppor
]);
}
- private function fakeGitHubForSubmission(Plugin $plugin): void
+ private function fakeGitHubForSubmission(Plugin $plugin, bool $passingChecks = true): void
{
$repoInfo = $plugin->getRepositoryOwnerAndName();
$base = "https://api.github.com/repos/{$repoInfo['owner']}/{$repoInfo['repo']}";
Http::fake([
- "{$base}/hooks" => Http::response(['id' => 1], 201),
+ "{$base}/hooks" => function ($request) {
+ if ($request->method() === 'GET') {
+ return Http::response([], 200);
+ }
+
+ return Http::response(['id' => 1], 201);
+ },
$base => Http::response(['default_branch' => 'main']),
"{$base}/git/trees/main*" => Http::response([
- 'tree' => [
- ['path' => 'src/ServiceProvider.php', 'type' => 'blob'],
- ],
+ 'tree' => $passingChecks
+ ? [
+ ['path' => 'src/ServiceProvider.php', 'type' => 'blob'],
+ ['path' => 'LICENSE', 'type' => 'blob'],
+ ]
+ : [
+ ['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}/contents/LICENSE*" => $passingChecks
+ ? Http::response(['name' => 'LICENSE', 'type' => 'file'], 200)
+ : Http::response([], 404),
+ "{$base}/releases/latest" => $passingChecks
+ ? Http::response(['tag_name' => 'v1.0.0'], 200)
+ : Http::response([], 404),
+ "{$base}/tags*" => $passingChecks
+ ? Http::response([['name' => 'v1.0.0']])
+ : Http::response([]),
"{$base}/readme" => Http::response([
'content' => base64_encode('# Plugin'),
'encoding' => 'base64',
@@ -106,6 +123,19 @@ public function test_submit_draft_for_review(): void
Notification::assertSentTo($user, PluginSubmitted::class);
}
+ public function test_submit_requires_description(): void
+ {
+ $user = $this->createGitHubUser();
+ $plugin = $this->createDraftPlugin($user);
+ $plugin->update(['description' => null]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('submitForReview');
+
+ $plugin->refresh();
+ $this->assertEquals(PluginStatus::Draft, $plugin->status);
+ }
+
public function test_submit_requires_support_channel(): void
{
$user = $this->createGitHubUser();
@@ -439,4 +469,121 @@ public function test_submit_free_plugin_clears_tier(): void
$plugin->refresh();
$this->assertEquals(PluginStatus::Pending, $plugin->status);
}
+
+ // ========================================
+ // Preflight Checks Gate Submission
+ // ========================================
+
+ public function test_submit_blocked_when_required_checks_fail(): void
+ {
+ $user = $this->createGitHubUser();
+ $plugin = $this->createDraftPlugin($user);
+ $this->fakeGitHubForSubmission($plugin, passingChecks: false);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('submitForReview');
+
+ $plugin->refresh();
+ $this->assertEquals(PluginStatus::Draft, $plugin->status);
+ $this->assertNotNull($plugin->review_checks);
+ $this->assertFalse($plugin->passesRequiredReviewChecks());
+ }
+
+ public function test_run_preflight_checks_populates_review_checks(): void
+ {
+ $user = $this->createGitHubUser();
+ $plugin = $this->createDraftPlugin($user);
+ $this->fakeGitHubForSubmission($plugin);
+
+ $this->assertNull($plugin->review_checks);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('runPreflightChecks');
+
+ $plugin->refresh();
+ $this->assertNotNull($plugin->review_checks);
+ $this->assertTrue($plugin->review_checks['has_license_file']);
+ $this->assertTrue($plugin->review_checks['has_release_version']);
+ $this->assertTrue($plugin->webhook_installed);
+ }
+
+ public function test_submit_succeeds_after_preflight_checks_pass(): void
+ {
+ Notification::fake();
+ $user = $this->createGitHubUser();
+ $plugin = $this->createDraftPlugin($user);
+ $this->fakeGitHubForSubmission($plugin);
+
+ // Run checks first
+ $this->mountShowComponent($user, $plugin)
+ ->call('runPreflightChecks');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->passesRequiredReviewChecks());
+
+ // Now submit
+ $this->mountShowComponent($user, $plugin)
+ ->call('submitForReview');
+
+ $plugin->refresh();
+ $this->assertEquals(PluginStatus::Pending, $plugin->status);
+ Notification::assertSentTo($user, PluginSubmitted::class);
+ }
+
+ public function test_preflight_detects_manually_installed_webhook(): void
+ {
+ $user = $this->createGitHubUser();
+ $plugin = $this->createDraftPlugin($user);
+
+ // Simulate a webhook secret existing but not marked as installed
+ $plugin->generateWebhookSecret();
+ $plugin->update(['webhook_installed' => false]);
+
+ $repoInfo = $plugin->getRepositoryOwnerAndName();
+ $base = "https://api.github.com/repos/{$repoInfo['owner']}/{$repoInfo['repo']}";
+ $webhookUrl = $plugin->getWebhookUrl();
+
+ Http::fake([
+ // GET /hooks returns our webhook in the list
+ "{$base}/hooks" => function ($request) use ($webhookUrl) {
+ if ($request->method() === 'GET') {
+ return Http::response([
+ ['id' => 42, 'config' => ['url' => $webhookUrl]],
+ ], 200);
+ }
+
+ return Http::response(['id' => 1], 201);
+ },
+ $base => Http::response(['default_branch' => 'main']),
+ "{$base}/git/trees/main*" => Http::response([
+ 'tree' => [
+ ['path' => 'src/ServiceProvider.php', 'type' => 'blob'],
+ ['path' => 'LICENSE', 'type' => 'blob'],
+ ],
+ ]),
+ "{$base}/contents/composer.json*" => Http::response([
+ 'content' => base64_encode(json_encode(['name' => $plugin->name])),
+ 'encoding' => 'base64',
+ ]),
+ "{$base}/contents/LICENSE*" => Http::response(['name' => 'LICENSE', 'type' => 'file'], 200),
+ "{$base}/releases/latest" => Http::response(['tag_name' => 'v1.0.0'], 200),
+ "{$base}/tags*" => Http::response([['name' => 'v1.0.0']]),
+ "{$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),
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('runPreflightChecks');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->webhook_installed);
+ }
}
diff --git a/tests/Feature/Livewire/Customer/PluginWebhookCertificationTest.php b/tests/Feature/Livewire/Customer/PluginWebhookCertificationTest.php
new file mode 100644
index 00000000..5d660f0a
--- /dev/null
+++ b/tests/Feature/Livewire/Customer/PluginWebhookCertificationTest.php
@@ -0,0 +1,170 @@
+create([
+ 'github_id' => '12345',
+ 'github_username' => 'testuser',
+ 'github_token' => encrypt('fake-token'),
+ ]);
+ }
+
+ private function mountShowComponent(User $user, Plugin $plugin)
+ {
+ [$vendor, $package] = explode('/', $plugin->name);
+
+ return Livewire::actingAs($user)->test(Show::class, [
+ 'vendor' => $vendor,
+ 'package' => $package,
+ ]);
+ }
+
+ public function test_certify_webhook_marks_plugin_as_webhook_installed(): void
+ {
+ $user = $this->createUser();
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/certify-plugin',
+ 'webhook_secret' => bin2hex(random_bytes(32)),
+ 'webhook_installed' => false,
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('certifyWebhook');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->webhook_installed);
+ }
+
+ public function test_certify_webhook_generates_secret_if_missing(): void
+ {
+ $user = $this->createUser();
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/no-secret-plugin',
+ 'webhook_secret' => null,
+ 'webhook_installed' => false,
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('certifyWebhook');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->webhook_installed);
+ $this->assertNotNull($plugin->webhook_secret);
+ }
+
+ public function test_certify_webhook_is_idempotent(): void
+ {
+ $user = $this->createUser();
+ $secret = bin2hex(random_bytes(32));
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/already-installed',
+ 'webhook_secret' => $secret,
+ 'webhook_installed' => true,
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('certifyWebhook');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->webhook_installed);
+ $this->assertEquals($secret, $plugin->webhook_secret);
+ }
+
+ public function test_certify_webhook_makes_plugin_pass_webhook_check(): void
+ {
+ $user = $this->createUser();
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/check-plugin',
+ 'webhook_secret' => bin2hex(random_bytes(32)),
+ 'webhook_installed' => false,
+ 'review_checks' => [
+ 'has_license_file' => true,
+ 'has_release_version' => true,
+ ],
+ ]);
+
+ $this->assertFalse($plugin->passesRequiredReviewChecks());
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('certifyWebhook');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->passesRequiredReviewChecks());
+ }
+
+ public function test_retry_webhook_installs_successfully(): void
+ {
+ $user = $this->createUser();
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/retry-plugin',
+ 'repository_url' => 'https://github.com/testuser/retry-plugin',
+ 'webhook_secret' => bin2hex(random_bytes(32)),
+ 'webhook_installed' => false,
+ ]);
+
+ Http::fake([
+ 'https://api.github.com/repos/testuser/retry-plugin/hooks' => Http::response(['id' => 1], 201),
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('retryWebhook');
+
+ $plugin->refresh();
+ $this->assertTrue($plugin->webhook_installed);
+ }
+
+ public function test_retry_webhook_handles_failure(): void
+ {
+ $user = $this->createUser();
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/retry-fail-plugin',
+ 'repository_url' => 'https://github.com/testuser/retry-fail-plugin',
+ 'webhook_secret' => bin2hex(random_bytes(32)),
+ 'webhook_installed' => false,
+ ]);
+
+ Http::fake([
+ 'https://api.github.com/repos/testuser/retry-fail-plugin/hooks' => Http::response(['message' => 'Not Found'], 404),
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('retryWebhook');
+
+ $plugin->refresh();
+ $this->assertFalse($plugin->webhook_installed);
+ }
+
+ public function test_retry_webhook_without_github_token_shows_error(): void
+ {
+ $user = User::factory()->create([
+ 'github_id' => null,
+ 'github_token' => null,
+ ]);
+ $plugin = Plugin::factory()->pending()->for($user)->create([
+ 'name' => 'testuser/no-token-plugin',
+ 'repository_url' => 'https://github.com/testuser/no-token-plugin',
+ 'webhook_secret' => bin2hex(random_bytes(32)),
+ 'webhook_installed' => false,
+ ]);
+
+ $this->mountShowComponent($user, $plugin)
+ ->call('retryWebhook');
+
+ $plugin->refresh();
+ $this->assertFalse($plugin->webhook_installed);
+ }
+}