From 085569999df8e77cabaf1ceec5ed30a98487d729 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 25 Sep 2026 20:22:45 +0100 Subject: [PATCH 1/2] Suggest plugin categories with Jev and allow more than one per plugin Co-Authored-By: Claude Opus 5.5 --- .../Commands/AutoCategorisePlugins.php | 104 ++++++++++ .../Commands/BackfillPluginCategories.php | 4 +- app/Enums/PluginCategory.php | 16 ++ app/Filament/Resources/PluginResource.php | 72 +++++++ app/Jobs/SuggestPluginCategories.php | 115 +++++++++++ app/Livewire/Customer/Plugins/Create.php | 3 + app/Livewire/PluginDirectory.php | 6 +- app/Models/Plugin.php | 56 +++++- database/factories/PluginFactory.php | 6 +- ...171044_add_categories_to_plugins_table.php | 41 ++++ tests/Concerns/FakesJevCategoryAnswers.php | 24 +++ ...dCategoriesToPluginsTableMigrationTest.php | 35 ++++ tests/Feature/AutoCategorisePluginsTest.php | 115 +++++++++++ .../Feature/BackfillPluginCategoriesTest.php | 36 ++-- .../Feature/Filament/PluginCategoriesTest.php | 185 ++++++++++++++++++ .../Jobs/SuggestPluginCategoriesTest.php | 176 +++++++++++++++++ .../Livewire/Customer/PluginCreateTest.php | 61 +++++- tests/Feature/PluginDirectoryTest.php | 42 +++- tests/TestCase.php | 12 +- 19 files changed, 1077 insertions(+), 32 deletions(-) create mode 100644 app/Console/Commands/AutoCategorisePlugins.php create mode 100644 app/Jobs/SuggestPluginCategories.php create mode 100644 database/migrations/2026_09_25_171044_add_categories_to_plugins_table.php create mode 100644 tests/Concerns/FakesJevCategoryAnswers.php create mode 100644 tests/Feature/AddCategoriesToPluginsTableMigrationTest.php create mode 100644 tests/Feature/AutoCategorisePluginsTest.php create mode 100644 tests/Feature/Filament/PluginCategoriesTest.php create mode 100644 tests/Feature/Jobs/SuggestPluginCategoriesTest.php diff --git a/app/Console/Commands/AutoCategorisePlugins.php b/app/Console/Commands/AutoCategorisePlugins.php new file mode 100644 index 000000000..2d749dec7 --- /dev/null +++ b/app/Console/Commands/AutoCategorisePlugins.php @@ -0,0 +1,104 @@ +option('dry-run'); + + $plugins = Plugin::query()->orderBy('name')->get(); + + if ($plugins->isEmpty()) { + $this->info('There are no plugins to categorise.'); + + return self::SUCCESS; + } + + /** @var Collection $categorised */ + $categorised = new Collection; + /** @var Collection $unsure */ + $unsure = new Collection; + /** @var Collection $failed */ + $failed = new Collection; + + $this->withProgressBar($plugins, function (Plugin $plugin) use ($dryRun, $categorised, $unsure, $failed): void { + if ($plugin->category_suggestions === null) { + try { + (new SuggestPluginCategories($plugin))->handle(); + } catch (Throwable $exception) { + $failed->push([$plugin, $exception->getMessage()]); + + return; + } + } + + if ($plugin->suggestedCategories()->isEmpty()) { + $unsure->push($plugin); + + return; + } + + if (! $dryRun) { + $plugin->applySuggestedCategories(); + } + + $categorised->push($plugin); + }); + + $this->newLine(2); + + if ($categorised->isNotEmpty()) { + $this->table( + ['Plugin', $dryRun ? 'Jev Suggests' : 'Categories'], + $categorised->map(fn (Plugin $plugin): array => [ + $plugin->name, + $plugin->suggestedCategories()->map->label()->join(', '), + ])->all() + ); + } + + if ($unsure->isNotEmpty()) { + $this->newLine(); + $this->warn('Jev isn\'t confident about any category for these, so they were left as they are:'); + $this->table(['Plugin'], $unsure->map(fn (Plugin $plugin): array => [$plugin->name])->all()); + } + + if ($failed->isNotEmpty()) { + $this->newLine(); + $this->error('Jev couldn\'t be asked about these. Run the command again to retry them:'); + $this->table( + ['Plugin', 'Error'], + $failed->map(fn (array $row): array => [$row[0]->name, Str::limit($row[1], 100)])->all() + ); + } + + $this->newLine(); + $this->info(sprintf('%d categorised, %d left as they are, %d failed.', $categorised->count(), $unsure->count(), $failed->count())); + + if ($dryRun) { + $this->warn('Dry run, so no categories were changed. Run it again without --dry-run to apply these. Jev won\'t be asked again.'); + } + + return $failed->isEmpty() ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/BackfillPluginCategories.php b/app/Console/Commands/BackfillPluginCategories.php index 3693ae591..cc1b8c84c 100644 --- a/app/Console/Commands/BackfillPluginCategories.php +++ b/app/Console/Commands/BackfillPluginCategories.php @@ -21,7 +21,7 @@ public function handle(PluginCategoryClassifier $classifier): int { $dryRun = (bool) $this->option('dry-run'); - $plugins = Plugin::query()->whereNull('category')->get(); + $plugins = Plugin::query()->uncategorized()->get(); if ($plugins->isEmpty()) { $this->info('No plugins need a category backfilled.'); @@ -46,7 +46,7 @@ public function handle(PluginCategoryClassifier $classifier): int $matched->push([$plugin, $category]); if (! $dryRun) { - $plugin->update(['category' => $category]); + $plugin->update(['categories' => [$category]]); } } diff --git a/app/Enums/PluginCategory.php b/app/Enums/PluginCategory.php index 80633f864..153158889 100644 --- a/app/Enums/PluginCategory.php +++ b/app/Enums/PluginCategory.php @@ -24,4 +24,20 @@ public function label(): string self::System => 'System', }; } + + /** + * What a plugin in this category is for, so Jev can tell whether a plugin fits it. + */ + public function description(): string + { + return match ($this) { + self::Media => 'cameras, photos, video, audio, barcode and QR code scanning, or speech', + self::Security => 'biometrics like Face ID and fingerprints, authentication, encryption, or secure storage like the keychain', + self::Connectivity => 'Bluetooth, NFC, Wi-Fi, networking, websockets, beacons or deep links', + self::Notifications => 'push or local notifications, alerts or reminders', + self::Payments => 'in-app purchases, subscriptions, payment providers like Stripe and PayPal, wallets, or ads', + self::Analytics => 'usage analytics, crash reporting, tracking, metrics or telemetry', + self::System => 'device and operating system features like device info, battery, sensors, haptics, the clipboard, keyboard, screen orientation and brightness, the status bar, splash screen, app icon, widgets, background tasks, the file picker, contacts, calendar, geolocation, maps or health data', + }; + } } diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index 227c050a7..5a34650e9 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -2,12 +2,14 @@ namespace App\Filament\Resources; +use App\Enums\PluginCategory; use App\Enums\PluginStatus; use App\Enums\PluginTier; use App\Enums\PluginType; use App\Filament\Resources\PluginResource\Pages; use App\Filament\Resources\PluginResource\RelationManagers; use App\Jobs\ReviewPluginRepository; +use App\Jobs\SuggestPluginCategories; use App\Jobs\SyncPlugin; use App\Models\Plugin; use App\Models\PluginLicense; @@ -20,12 +22,14 @@ use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\HtmlString; +use Throwable; class PluginResource extends Resource { @@ -128,6 +132,74 @@ public static function form(Schema $schema): Schema ->helperText('Show a badge indicating this plugin runs in the Jump preview app (i.e. it ships no custom native code)'), ]), + Schemas\Components\Section::make('Categories') + ->key('categories-section') + ->inlineLabel() + ->columns(1) + ->schema([ + Forms\Components\CheckboxList::make('categories') + ->options(PluginCategory::class) + ->columns(4), + + Forms\Components\Placeholder::make('category_suggestions_display') + ->label('Jev suggests') + ->content(fn (?Plugin $record): string => match (true) { + $record?->category_suggestions === null => 'Not asked yet', + $record->suggestedCategories()->isEmpty() => 'No category is a confident fit', + default => $record->suggestedCategories() + ->map(fn (PluginCategory $category): string => sprintf('%s (%d%%)', $category->label(), round($record->category_suggestions[$category->value] * 100))) + ->join(', '), + }), + ]) + ->headerActions([ + Action::make('suggestCategories') + ->label(fn (?Plugin $record): string => $record?->category_suggestions === null ? 'Ask Jev' : 'Ask Jev again') + ->icon('heroicon-o-sparkles') + ->color('gray') + ->action(function (Plugin $record): void { + try { + (new SuggestPluginCategories($record))->handle(); + } catch (Throwable $exception) { + report($exception); + + Notification::make() + ->title('Jev couldn\'t suggest categories') + ->body($exception->getMessage()) + ->danger() + ->send(); + + return; + } + + $suggestions = $record->suggestedCategories(); + + Notification::make() + ->title($suggestions->isEmpty() + ? 'Jev isn\'t confident about any category' + : 'Jev suggests '.$suggestions->map->label()->join(', ', ' and ')) + ->status($suggestions->isEmpty() ? 'warning' : 'success') + ->send(); + }), + + Action::make('applySuggestedCategories') + ->label('Apply suggestions') + ->icon('heroicon-o-check') + ->color('success') + ->visible(fn (?Plugin $record): bool => (bool) $record?->hasUnappliedCategorySuggestions()) + ->action(function (Plugin $record, Set $set): void { + $record->applySuggestedCategories(); + + $set('categories', $record->categories->map->value->all()); + + Notification::make() + ->title('Categories applied') + ->body('This plugin is now in '.$record->categories->map->label()->join(', ', ' and ').'.') + ->success() + ->send(); + }), + ]) + ->visible(fn (?Plugin $record) => $record !== null), + Schemas\Components\Section::make('Review Checks') ->inlineLabel() ->columns(1) diff --git a/app/Jobs/SuggestPluginCategories.php b/app/Jobs/SuggestPluginCategories.php new file mode 100644 index 000000000..573b6040b --- /dev/null +++ b/app/Jobs/SuggestPluginCategories.php @@ -0,0 +1,115 @@ +state()) + ->questions($this->questions()) + ->classify() + ->collect(); + + $this->plugin->update([ + 'category_suggestions' => $answers + ->only(array_column(PluginCategory::cases(), 'value')) + ->filter(fn (Answer $answer): bool => $answer instanceof BooleanAnswer && $answer->isTrue(self::THRESHOLD)) + ->map(fn (BooleanAnswer $answer): float => round($answer->probability, 4)) + ->sortDesc() + ->take(self::MAX_SUGGESTIONS) + ->all(), + ]); + } + + /** + * @return array + */ + protected function state(): array + { + return array_filter([ + 'title' => $this->plugin->display_name ?? $this->plugin->name, + 'description' => $this->plugin->description, + 'readme' => $this->readme(), + ]); + } + + /** + * The README as plain text, without the "#" link the site adds to every + * heading. Tags are spaced out before they're stripped so that text from + * neighbouring elements doesn't run together. + */ + protected function readme(): ?string + { + if (blank($this->plugin->readme_html)) { + return null; + } + + $html = preg_replace('/]*\bheading-anchor\b[^>]*>.*?<\/a>/s', '', $this->plugin->readme_html); + + $text = html_entity_decode(strip_tags(str_replace('<', ' <', $html)), ENT_QUOTES | ENT_HTML5); + + return Str::limit(Str::squish($text), self::README_CHARACTER_LIMIT); + } + + /** + * One yes/no question per category, so a plugin that fits several isn't + * forced to split Jev's confidence between them. + * + * @return array + */ + protected function questions(): array + { + return collect(PluginCategory::cases()) + ->mapWithKeys(fn (PluginCategory $category): array => [ + $category->value => new Boolean( + sprintf(self::INSTRUCTIONS, $category->label()), + ['true' => "The plugin is for {$category->description()}."], + ), + ]) + ->all(); + } +} diff --git a/app/Livewire/Customer/Plugins/Create.php b/app/Livewire/Customer/Plugins/Create.php index ab90afa92..dd432aedc 100644 --- a/app/Livewire/Customer/Plugins/Create.php +++ b/app/Livewire/Customer/Plugins/Create.php @@ -4,6 +4,7 @@ use App\Enums\PluginStatus; use App\Features\AllowPaidPlugins; +use App\Jobs\SuggestPluginCategories; use App\Models\Plugin; use App\Services\GitHubUserService; use App\Services\PluginSyncService; @@ -203,6 +204,8 @@ function ($attribute, $value, $fail): void { return; } + SuggestPluginCategories::dispatch($plugin); + [$vendor, $package] = explode('/', $plugin->name); $this->redirect( diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php index 221c23278..0c2699359 100644 --- a/app/Livewire/PluginDirectory.php +++ b/app/Livewire/PluginDirectory.php @@ -23,7 +23,7 @@ class PluginDirectory extends Component use WithPagination; /** - * Filter value representing plugins with no `category` recorded. + * Filter value representing plugins that aren't in any category. */ public const string CATEGORY_UNCATEGORIZED = 'uncategorized'; @@ -169,12 +169,12 @@ public function render(): View }) ->when($this->category !== '', function (Builder $query): void { if ($this->category === self::CATEGORY_UNCATEGORIZED) { - $query->whereNull('category'); + $query->uncategorized(); return; } - $query->where('category', $this->category); + $query->whereJsonContains('categories', $this->category); }) ->when($this->mobileVersion !== '', function (Builder $query): void { if ($this->mobileVersion === self::MOBILE_VERSION_UNSPECIFIED) { diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index fe92d686a..f64a63bfd 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -10,6 +10,7 @@ use App\Enums\PriceTier; use App\Jobs\RemovePluginFromSatis; use App\Jobs\SendNewPluginNotifications; +use App\Jobs\SuggestPluginCategories; use App\Jobs\SyncPluginReleases; use App\Notifications\PluginApproved; use App\Notifications\PluginDeveloperReplied; @@ -23,6 +24,7 @@ use BladeUI\Icons\Factory as IconFactory; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\AsEnumCollection; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,6 +33,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Notification; class Plugin extends Model @@ -480,6 +483,54 @@ protected function featured(Builder $query): Builder return $query->where('featured', true); } + /** + * Plugins that aren't in any category yet. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function uncategorized(Builder $query): Builder + { + return $query->where(fn (Builder $query): Builder => $query + ->whereNull('categories') + ->orWhereJsonLength('categories', 0)); + } + + /** + * The categories Jev suggested for this plugin, likeliest first. + * + * @return Collection + */ + public function suggestedCategories(): Collection + { + return collect($this->category_suggestions) + ->sortDesc() + ->keys() + ->map(fn (string $category): ?PluginCategory => PluginCategory::tryFrom($category)) + ->filter() + ->values(); + } + + /** + * Whether applying Jev's suggestions would change the plugin's categories. + */ + public function hasUnappliedCategorySuggestions(): bool + { + $suggested = $this->suggestedCategories()->map->value->sort()->values()->all(); + $current = collect($this->categories)->map->value->sort()->values()->all(); + + return $suggested !== [] && $suggested !== $current; + } + + /** + * Put the plugin in the categories Jev suggested, and only those. + */ + public function applySuggestedCategories(): void + { + $this->update(['categories' => $this->suggestedCategories()]); + } + public function getPackagistUrl(): string { return "https://packagist.org/packages/{$this->name}"; @@ -839,6 +890,8 @@ public function submit(): void ); $this->syncToSatis(); + + SuggestPluginCategories::dispatch($this); } /** @@ -968,7 +1021,8 @@ protected function casts(): array 'status' => PluginStatus::class, 'type' => PluginType::class, 'tier' => PluginTier::class, - 'category' => PluginCategory::class, + 'categories' => AsEnumCollection::of(PluginCategory::class), + 'category_suggestions' => 'array', 'approved_at' => 'datetime', 'featured' => 'boolean', 'is_active' => 'boolean', diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index 856ba1318..fba8a3b9a 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -115,7 +115,7 @@ public function definition(): array 'demo_video_attested_at' => now(), 'show_demo_video' => false, 'type' => PluginType::Free, - 'category' => null, + 'categories' => null, 'status' => PluginStatus::Pending, 'featured' => false, 'rejection_reason' => null, @@ -209,10 +209,10 @@ public function paid(): static ]); } - public function category(PluginCategory $category): static + public function categories(PluginCategory ...$categories): static { return $this->state(fn (array $attributes) => [ - 'category' => $category, + 'categories' => $categories, ]); } diff --git a/database/migrations/2026_09_25_171044_add_categories_to_plugins_table.php b/database/migrations/2026_09_25_171044_add_categories_to_plugins_table.php new file mode 100644 index 000000000..f9df9f66c --- /dev/null +++ b/database/migrations/2026_09_25_171044_add_categories_to_plugins_table.php @@ -0,0 +1,41 @@ +json('categories')->nullable()->after('category'); + $table->json('category_suggestions')->nullable()->after('categories'); + }); + + DB::table('plugins') + ->whereNotNull('category') + ->distinct() + ->pluck('category') + ->each(fn (string $category) => DB::table('plugins') + ->where('category', $category) + ->update(['categories' => json_encode([$category])])); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn(['categories', 'category_suggestions']); + }); + } +}; diff --git a/tests/Concerns/FakesJevCategoryAnswers.php b/tests/Concerns/FakesJevCategoryAnswers.php new file mode 100644 index 000000000..0a65fb855 --- /dev/null +++ b/tests/Concerns/FakesJevCategoryAnswers.php @@ -0,0 +1,24 @@ + $probabilities + * @return array + */ + protected function categoryAnswers(array $probabilities): array + { + return collect(PluginCategory::cases()) + ->mapWithKeys(fn (PluginCategory $category): array => [ + $category->value => new BooleanAnswer($probabilities[$category->value] ?? 0.0), + ]) + ->all(); + } +} diff --git a/tests/Feature/AddCategoriesToPluginsTableMigrationTest.php b/tests/Feature/AddCategoriesToPluginsTableMigrationTest.php new file mode 100644 index 000000000..453e8d4c1 --- /dev/null +++ b/tests/Feature/AddCategoriesToPluginsTableMigrationTest.php @@ -0,0 +1,35 @@ +create(['category' => PluginCategory::Media->value]); + $payments = Plugin::factory()->create(['category' => PluginCategory::Payments->value]); + $uncategorized = Plugin::factory()->create(['category' => null]); + + $migration = $this->migration(); + $migration->down(); + $migration->up(); + + $this->assertSame([PluginCategory::Media], $media->fresh()->categories->all()); + $this->assertSame([PluginCategory::Payments], $payments->fresh()->categories->all()); + $this->assertNull($uncategorized->fresh()->categories); + $this->assertNull($media->fresh()->category_suggestions); + } + + private function migration(): Migration + { + return require database_path('migrations/2026_09_25_171044_add_categories_to_plugins_table.php'); + } +} diff --git a/tests/Feature/AutoCategorisePluginsTest.php b/tests/Feature/AutoCategorisePluginsTest.php new file mode 100644 index 000000000..8d44de895 --- /dev/null +++ b/tests/Feature/AutoCategorisePluginsTest.php @@ -0,0 +1,115 @@ + match ($prompt->state['title']) { + 'acme/camera' => $this->categoryAnswers([PluginCategory::Media->value => 0.9]), + 'acme/stripe' => $this->categoryAnswers([PluginCategory::Payments->value => 0.95, PluginCategory::Analytics->value => 0.6]), + }); + + $camera = Plugin::factory()->categories(PluginCategory::System)->create(['name' => 'acme/camera']); + $stripe = Plugin::factory()->create(['name' => 'acme/stripe']); + + $this->artisan('plugins:auto-categorise') + ->expectsTable(['Plugin', 'Categories'], [ + ['acme/camera', 'Media'], + ['acme/stripe', 'Payments, Analytics'], + ]) + ->expectsOutput('2 categorised, 0 left as they are, 0 failed.') + ->assertSuccessful(); + + $this->assertSame([PluginCategory::Media], $camera->fresh()->categories->all()); + $this->assertSame([PluginCategory::Payments, PluginCategory::Analytics], $stripe->fresh()->categories->all()); + } + + public function test_plugins_jev_is_not_confident_about_are_left_as_they_are(): void + { + Classification::fake([$this->categoryAnswers([PluginCategory::Media->value => 0.3])]); + + $plugin = Plugin::factory()->categories(PluginCategory::System)->create(['name' => 'acme/utility']); + + $this->artisan('plugins:auto-categorise') + ->expectsOutputToContain('Jev isn\'t confident about any category') + ->expectsOutput('0 categorised, 1 left as they are, 0 failed.') + ->assertSuccessful(); + + $plugin->refresh(); + + $this->assertSame([PluginCategory::System], $plugin->categories->all()); + $this->assertSame([], $plugin->category_suggestions); + } + + public function test_a_dry_run_asks_jev_without_changing_any_categories(): void + { + Classification::fake([$this->categoryAnswers([PluginCategory::Media->value => 0.9])]); + + $plugin = Plugin::factory()->categories(PluginCategory::System)->create(['name' => 'acme/camera']); + + $this->artisan('plugins:auto-categorise', ['--dry-run' => true]) + ->expectsTable(['Plugin', 'Jev Suggests'], [['acme/camera', 'Media']]) + ->expectsOutputToContain('Dry run, so no categories were changed.') + ->assertSuccessful(); + + $plugin->refresh(); + + $this->assertSame([PluginCategory::System], $plugin->categories->all()); + $this->assertSame([PluginCategory::Media], $plugin->suggestedCategories()->all()); + } + + public function test_plugins_jev_already_has_suggestions_for_are_not_asked_about_again(): void + { + Classification::fake(fn () => throw new RuntimeException('Jev should not be asked again')); + + $plugin = Plugin::factory()->create([ + 'name' => 'acme/camera', + 'category_suggestions' => [PluginCategory::Media->value => 0.9], + ]); + + $this->artisan('plugins:auto-categorise')->assertSuccessful(); + + $this->assertSame([PluginCategory::Media], $plugin->fresh()->categories->all()); + } + + public function test_a_plugin_jev_cannot_be_asked_about_is_reported_and_the_rest_carry_on(): void + { + Classification::fake(fn (ClassificationPrompt $prompt): array => $prompt->state['title'] === 'acme/broken' + ? throw new RuntimeException('Jev is unavailable') + : $this->categoryAnswers([PluginCategory::Media->value => 0.9])); + + $broken = Plugin::factory()->categories(PluginCategory::System)->create(['name' => 'acme/broken']); + $camera = Plugin::factory()->create(['name' => 'acme/camera']); + + $this->artisan('plugins:auto-categorise') + ->expectsTable(['Plugin', 'Error'], [['acme/broken', 'Jev is unavailable']]) + ->expectsOutput('1 categorised, 0 left as they are, 1 failed.') + ->assertFailed(); + + $broken->refresh(); + + $this->assertSame([PluginCategory::System], $broken->categories->all()); + $this->assertNull($broken->category_suggestions); + $this->assertSame([PluginCategory::Media], $camera->fresh()->categories->all()); + } + + public function test_reports_when_there_are_no_plugins(): void + { + $this->artisan('plugins:auto-categorise') + ->expectsOutput('There are no plugins to categorise.') + ->assertSuccessful(); + } +} diff --git a/tests/Feature/BackfillPluginCategoriesTest.php b/tests/Feature/BackfillPluginCategoriesTest.php index 8e4a2d341..16b76f23b 100644 --- a/tests/Feature/BackfillPluginCategoriesTest.php +++ b/tests/Feature/BackfillPluginCategoriesTest.php @@ -17,12 +17,12 @@ public function test_assigns_category_from_matching_keyword_in_name(): void 'name' => 'acme/camera-scanner', 'repository_url' => 'https://github.com/acme/camera-scanner', 'description' => null, - 'category' => null, + 'categories' => null, ]); $this->artisan('plugins:backfill-categories')->assertSuccessful(); - $this->assertSame(PluginCategory::Media, $plugin->fresh()->category); + $this->assertSame([PluginCategory::Media], $plugin->fresh()->categories->all()); } public function test_assigns_category_from_matching_keyword_in_description(): void @@ -35,35 +35,49 @@ public function test_assigns_category_from_matching_keyword_in_description(): vo 'name' => 'acme/checkout-kit', 'repository_url' => 'https://github.com/acme/checkout-kit', 'description' => 'Accept Stripe payments in your NativePHP app.', - 'category' => null, + 'categories' => null, ]); $this->artisan('plugins:backfill-categories')->assertSuccessful(); - $this->assertSame(PluginCategory::Payments, $plugin->fresh()->category); + $this->assertSame([PluginCategory::Payments], $plugin->fresh()->categories->all()); + } + + public function test_plugins_whose_categories_were_all_removed_are_backfilled_too(): void + { + $plugin = Plugin::factory()->create([ + 'name' => 'acme/camera-scanner', + 'repository_url' => 'https://github.com/acme/camera-scanner', + 'description' => null, + 'categories' => [], + ]); + + $this->artisan('plugins:backfill-categories')->assertSuccessful(); + + $this->assertSame([PluginCategory::Media], $plugin->fresh()->categories->all()); } public function test_dry_run_does_not_persist_proposed_categories(): void { $plugin = Plugin::factory()->create([ 'name' => 'acme/push-notifications', - 'category' => null, + 'categories' => null, ]); $this->artisan('plugins:backfill-categories', ['--dry-run' => true])->assertSuccessful(); - $this->assertNull($plugin->fresh()->category); + $this->assertNull($plugin->fresh()->categories); } public function test_already_categorized_plugins_are_left_alone(): void { - $plugin = Plugin::factory()->category(PluginCategory::System)->create([ + $plugin = Plugin::factory()->categories(PluginCategory::System)->create([ 'name' => 'acme/camera-scanner', ]); $this->artisan('plugins:backfill-categories')->assertSuccessful(); - $this->assertSame(PluginCategory::System, $plugin->fresh()->category); + $this->assertSame([PluginCategory::System], $plugin->fresh()->categories->all()); } public function test_plugins_with_no_confident_match_stay_uncategorized(): void @@ -75,19 +89,19 @@ public function test_plugins_with_no_confident_match_stay_uncategorized(): void 'name' => 'acme/utility-tool', 'repository_url' => 'https://github.com/acme/utility-tool', 'description' => 'A simple utility with miscellaneous helper functions for your app.', - 'category' => null, + 'categories' => null, ]); $this->artisan('plugins:backfill-categories') ->expectsOutputToContain('No confident match') ->assertSuccessful(); - $this->assertNull($plugin->fresh()->category); + $this->assertNull($plugin->fresh()->categories); } public function test_reports_nothing_to_do_when_no_plugins_need_backfilling(): void { - Plugin::factory()->category(PluginCategory::Media)->create(); + Plugin::factory()->categories(PluginCategory::Media)->create(); $this->artisan('plugins:backfill-categories') ->expectsOutput('No plugins need a category backfilled.') diff --git a/tests/Feature/Filament/PluginCategoriesTest.php b/tests/Feature/Filament/PluginCategoriesTest.php new file mode 100644 index 000000000..2cc322a88 --- /dev/null +++ b/tests/Feature/Filament/PluginCategoriesTest.php @@ -0,0 +1,185 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_categories_can_be_edited_on_the_plugin_form(): void + { + $plugin = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->fillForm(['categories' => [PluginCategory::Media->value, PluginCategory::System->value]]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertSame([PluginCategory::Media, PluginCategory::System], $plugin->fresh()->categories->all()); + } + + public function test_jevs_suggestions_are_shown_with_how_sure_it_is(): void + { + $plugin = Plugin::factory()->approved()->create([ + 'category_suggestions' => [PluginCategory::System->value => 0.71, PluginCategory::Media->value => 0.92], + ]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertSee('Media (92%), System (71%)'); + } + + public function test_the_form_says_when_jev_has_not_been_asked_yet(): void + { + $plugin = Plugin::factory()->approved()->create(['category_suggestions' => null]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertSee('Not asked yet') + ->assertActionHasLabel($this->action('suggestCategories'), 'Ask Jev'); + } + + public function test_asking_jev_stores_and_shows_its_suggestions(): void + { + Classification::fake([$this->categoryAnswers([ + PluginCategory::Media->value => 0.92, + PluginCategory::System->value => 0.71, + ])]); + + $plugin = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction($this->action('suggestCategories')) + ->assertNotified('Jev suggests Media and System') + ->assertSee('Media (92%), System (71%)') + ->assertActionHasLabel($this->action('suggestCategories'), 'Ask Jev again'); + + $this->assertSame([PluginCategory::Media, PluginCategory::System], $plugin->fresh()->suggestedCategories()->all()); + } + + public function test_asking_jev_says_so_when_no_category_fits(): void + { + Classification::fake([$this->categoryAnswers([])]); + + $plugin = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction($this->action('suggestCategories')) + ->assertNotified('Jev isn\'t confident about any category') + ->assertSee('No category is a confident fit') + ->assertActionDoesNotExist($this->action('applySuggestedCategories')); + } + + public function test_the_admin_is_told_when_jev_cannot_be_reached(): void + { + Exceptions::fake(); + Classification::fake(fn () => throw new RuntimeException('Jev is unavailable')); + + $plugin = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction($this->action('suggestCategories')) + ->assertNotified('Jev couldn\'t suggest categories'); + + $this->assertNull($plugin->fresh()->category_suggestions); + Exceptions::assertReported(RuntimeException::class); + } + + public function test_applying_suggestions_puts_the_plugin_in_exactly_those_categories(): void + { + $plugin = Plugin::factory()->approved()->categories(PluginCategory::Analytics)->create([ + 'category_suggestions' => [PluginCategory::Notifications->value => 0.9, PluginCategory::System->value => 0.6], + ]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction($this->action('applySuggestedCategories')) + ->assertNotified('Categories applied') + ->assertSchemaStateSet(['categories' => [PluginCategory::Notifications->value, PluginCategory::System->value]]) + ->assertActionDoesNotExist($this->action('applySuggestedCategories')); + + $this->assertSame([PluginCategory::Notifications, PluginCategory::System], $plugin->fresh()->categories->all()); + } + + public function test_saving_the_form_after_applying_suggestions_keeps_them(): void + { + $plugin = Plugin::factory()->approved()->categories(PluginCategory::Analytics)->create([ + 'category_suggestions' => [PluginCategory::Notifications->value => 0.9], + ]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->callAction($this->action('applySuggestedCategories')) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertSame([PluginCategory::Notifications], $plugin->fresh()->categories->all()); + } + + public function test_apply_is_hidden_until_jev_has_suggested_something(): void + { + $plugin = Plugin::factory()->approved()->create(['category_suggestions' => null]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionDoesNotExist($this->action('applySuggestedCategories')); + } + + public function test_apply_is_hidden_when_the_plugin_is_already_in_the_suggested_categories(): void + { + $plugin = Plugin::factory()->approved()->categories(PluginCategory::System, PluginCategory::Media)->create([ + 'category_suggestions' => [PluginCategory::Media->value => 0.9, PluginCategory::System->value => 0.6], + ]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionDoesNotExist($this->action('applySuggestedCategories')); + } + + public function test_apply_is_offered_when_the_plugin_is_in_other_categories_too(): void + { + $plugin = Plugin::factory()->approved()->categories(PluginCategory::Media, PluginCategory::Payments)->create([ + 'category_suggestions' => [PluginCategory::Media->value => 0.9], + ]); + + Livewire::actingAs($this->admin) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionVisible($this->action('applySuggestedCategories')); + } + + /** + * An action in the Categories section's header. Filament can't find one of + * these while it's hidden, so hidden ones are asserted as not existing. + */ + private function action(string $name): TestAction + { + return TestAction::make($name)->schemaComponent('categories-section'); + } +} diff --git a/tests/Feature/Jobs/SuggestPluginCategoriesTest.php b/tests/Feature/Jobs/SuggestPluginCategoriesTest.php new file mode 100644 index 000000000..9a8cbe040 --- /dev/null +++ b/tests/Feature/Jobs/SuggestPluginCategoriesTest.php @@ -0,0 +1,176 @@ +create([ + 'display_name' => 'Barcode Scanner', + 'description' => 'Scan barcodes with the camera', + 'readme_html' => '

Barcode Scanner#

Scan barcodes & QR codes.

  • Fast
  • Offline
', + ]); + + SuggestPluginCategories::dispatchSync($plugin); + + Classification::assertClassified(function (ClassificationPrompt $prompt): bool { + $media = $prompt->questions[PluginCategory::Media->value]; + + return $prompt->state === [ + 'title' => 'Barcode Scanner', + 'description' => 'Scan barcodes with the camera', + 'readme' => 'Barcode Scanner Scan barcodes & QR codes. Fast Offline', + ] + && array_keys($prompt->questions) === array_column(PluginCategory::cases(), 'value') + && $media instanceof Boolean + && $media->instructions === 'Should this plugin be listed in the Media category of the NativePHP plugin marketplace? A plugin can be listed in more than one category.' + && $media->criteria === ['true' => 'The plugin is for cameras, photos, video, audio, barcode and QR code scanning, or speech.']; + }); + } + + public function test_the_package_name_stands_in_for_a_missing_display_name(): void + { + $plugin = Plugin::factory()->withoutDescription()->create([ + 'name' => 'acme/barcode-scanner', + 'display_name' => null, + 'readme_html' => null, + ]); + + SuggestPluginCategories::dispatchSync($plugin); + + Classification::assertClassified(fn (ClassificationPrompt $prompt): bool => $prompt->state === ['title' => 'acme/barcode-scanner']); + } + + public function test_jev_only_reads_the_start_of_a_long_readme(): void + { + $plugin = Plugin::factory()->create([ + 'readme_html' => '

'.str_repeat('a', SuggestPluginCategories::README_CHARACTER_LIMIT + 1000).'

', + ]); + + SuggestPluginCategories::dispatchSync($plugin); + + Classification::assertClassified(fn (ClassificationPrompt $prompt): bool => $prompt->state['readme'] === str_repeat('a', SuggestPluginCategories::README_CHARACTER_LIMIT).'...'); + } + + public function test_the_categories_jev_is_confident_about_are_suggested_likeliest_first(): void + { + Classification::fake([$this->categoryAnswers([ + PluginCategory::System->value => 0.7, + PluginCategory::Media->value => 0.9, + PluginCategory::Payments->value => 0.2, + ])]); + + $plugin = Plugin::factory()->create(); + + SuggestPluginCategories::dispatchSync($plugin); + + $plugin->refresh(); + + $this->assertEquals([PluginCategory::Media->value => 0.9, PluginCategory::System->value => 0.7], $plugin->category_suggestions); + $this->assertSame([PluginCategory::Media, PluginCategory::System], $plugin->suggestedCategories()->all()); + } + + public function test_a_category_right_on_the_threshold_is_suggested(): void + { + Classification::fake([$this->categoryAnswers([ + PluginCategory::Security->value => SuggestPluginCategories::THRESHOLD, + ])]); + + $plugin = Plugin::factory()->create(); + + SuggestPluginCategories::dispatchSync($plugin); + + $this->assertSame([PluginCategory::Security], $plugin->fresh()->suggestedCategories()->all()); + } + + public function test_no_more_than_three_categories_are_suggested(): void + { + Classification::fake([$this->categoryAnswers([ + PluginCategory::Media->value => 0.6, + PluginCategory::Connectivity->value => 0.95, + PluginCategory::System->value => 0.8, + PluginCategory::Analytics->value => 0.55, + PluginCategory::Notifications->value => 0.9, + ])]); + + $plugin = Plugin::factory()->create(); + + SuggestPluginCategories::dispatchSync($plugin); + + $this->assertSame( + [PluginCategory::Connectivity, PluginCategory::Notifications, PluginCategory::System], + $plugin->fresh()->suggestedCategories()->all(), + ); + } + + public function test_nothing_is_suggested_when_jev_is_not_confident_about_any_category(): void + { + Classification::fake([$this->categoryAnswers([ + PluginCategory::Media->value => 0.4, + PluginCategory::System->value => 0.3, + ])]); + + $plugin = Plugin::factory()->create(); + + SuggestPluginCategories::dispatchSync($plugin); + + $plugin->refresh(); + + $this->assertSame([], $plugin->category_suggestions); + $this->assertTrue($plugin->suggestedCategories()->isEmpty()); + } + + public function test_suggestions_come_back_likeliest_first_however_they_were_stored(): void + { + $plugin = Plugin::factory()->create([ + 'category_suggestions' => [ + PluginCategory::System->value => 0.6, + PluginCategory::Payments->value => 0.8, + 'retired-category' => 0.99, + ], + ]); + + $this->assertSame([PluginCategory::Payments, PluginCategory::System], $plugin->suggestedCategories()->all()); + } + + public function test_a_failed_classification_leaves_earlier_suggestions_alone(): void + { + Classification::fake(fn () => throw new RuntimeException('Jev is unavailable')); + + $plugin = Plugin::factory()->create(['category_suggestions' => [PluginCategory::Media->value => 0.9]]); + + $this->assertThrows( + fn () => (new SuggestPluginCategories($plugin))->handle(), + RuntimeException::class, + 'Jev is unavailable', + ); + + $this->assertEquals([PluginCategory::Media->value => 0.9], $plugin->fresh()->category_suggestions); + } + + public function test_submitting_a_plugin_for_review_asks_jev_for_categories(): void + { + Queue::fake(); + + $plugin = Plugin::factory()->draft()->create(); + + $plugin->submit(); + + Queue::assertPushed(SuggestPluginCategories::class, fn (SuggestPluginCategories $job): bool => $job->plugin->is($plugin)); + } +} diff --git a/tests/Feature/Livewire/Customer/PluginCreateTest.php b/tests/Feature/Livewire/Customer/PluginCreateTest.php index 0cbbe2c0c..5f7793baf 100644 --- a/tests/Feature/Livewire/Customer/PluginCreateTest.php +++ b/tests/Feature/Livewire/Customer/PluginCreateTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Livewire\Customer; +use App\Enums\PluginCategory; use App\Features\ShowAuthButtons; use App\Features\ShowPlugins; use App\Livewire\Customer\Plugins\Create; @@ -9,13 +10,16 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Laravel\Ai\Classification; +use Laravel\Ai\Prompts\ClassificationPrompt; use Laravel\Pennant\Feature; use Livewire\Livewire; +use Tests\Concerns\FakesJevCategoryAnswers; use Tests\TestCase; class PluginCreateTest extends TestCase { - use RefreshDatabase; + use FakesJevCategoryAnswers, RefreshDatabase; protected function setUp(): void { @@ -245,4 +249,59 @@ public function test_plugin_created_as_draft(): void $plugin = Plugin::where('repository_url', 'https://github.com/testuser/draft-plugin')->first(); $this->assertNull($plugin->webhook_secret); } + + // ======================================== + // Category Suggestion Tests + // ======================================== + + public function test_jev_suggests_categories_for_a_new_plugin_from_its_synced_details(): void + { + Classification::fake([$this->categoryAnswers([PluginCategory::Media->value => 0.9])]); + + $user = $this->createGitHubUser(); + + Http::fake([ + 'api.github.com/repos/testuser/camera-plugin/contents/composer.json*' => Http::response([ + 'content' => base64_encode(json_encode(['name' => 'testuser/camera-plugin', 'description' => 'Take photos'])), + ]), + 'api.github.com/repos/testuser/camera-plugin/contents/README.md*' => Http::response([ + 'content' => base64_encode("# Camera Plugin\n\nTake photos with the device camera."), + ]), + 'api.github.com/*' => Http::response([], 404), + 'raw.githubusercontent.com/*' => Http::response('', 404), + ]); + + Livewire::actingAs($user)->test(Create::class) + ->set('repository', 'testuser/camera-plugin') + ->set('pluginType', 'free') + ->call('createPlugin'); + + Classification::assertClassified(fn (ClassificationPrompt $prompt): bool => $prompt->state === [ + 'title' => 'testuser/camera-plugin', + 'description' => 'Take photos', + 'readme' => 'Camera Plugin Take photos with the device camera.', + ]); + + $plugin = Plugin::where('name', 'testuser/camera-plugin')->sole(); + + $this->assertSame([PluginCategory::Media], $plugin->suggestedCategories()->all()); + $this->assertNull($plugin->categories); + } + + public function test_jev_is_not_asked_about_a_plugin_that_could_not_be_created(): void + { + $user = $this->createGitHubUser(); + + Http::fake([ + 'api.github.com/*' => Http::response([], 404), + 'raw.githubusercontent.com/*' => Http::response('', 404), + ]); + + Livewire::actingAs($user)->test(Create::class) + ->set('repository', 'testuser/no-composer') + ->set('pluginType', 'free') + ->call('createPlugin'); + + Classification::assertNothingClassified(); + } } diff --git a/tests/Feature/PluginDirectoryTest.php b/tests/Feature/PluginDirectoryTest.php index 94d0ffdcb..42ff21b3e 100644 --- a/tests/Feature/PluginDirectoryTest.php +++ b/tests/Feature/PluginDirectoryTest.php @@ -69,28 +69,50 @@ public function test_type_filter_narrows_to_paid_plugins(): void public function test_category_filter_narrows_to_matching_plugins(): void { - $media = Plugin::factory()->approved()->category(PluginCategory::Media)->create(); - Plugin::factory()->approved()->category(PluginCategory::Payments)->create(); + $media = Plugin::factory()->approved()->categories(PluginCategory::Media)->create(); + Plugin::factory()->approved()->categories(PluginCategory::Payments)->create(); Livewire::test(PluginDirectory::class) ->set('category', PluginCategory::Media->value) ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$media->id]); } + public function test_category_filter_finds_a_plugin_under_each_of_its_categories(): void + { + $scanner = Plugin::factory()->approved()->categories(PluginCategory::Media, PluginCategory::System)->create(); + Plugin::factory()->approved()->categories(PluginCategory::Payments)->create(); + + Livewire::test(PluginDirectory::class) + ->set('category', PluginCategory::Media->value) + ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$scanner->id]) + ->set('category', PluginCategory::System->value) + ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$scanner->id]); + } + public function test_category_filter_uncategorized_bucket_returns_only_null_category_plugins(): void { - $uncategorized = Plugin::factory()->approved()->create(['category' => null]); - Plugin::factory()->approved()->category(PluginCategory::Media)->create(); + $uncategorized = Plugin::factory()->approved()->create(['categories' => null]); + Plugin::factory()->approved()->categories(PluginCategory::Media)->create(); Livewire::test(PluginDirectory::class) ->set('category', PluginDirectory::CATEGORY_UNCATEGORIZED) ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$uncategorized->id]); } + public function test_category_filter_uncategorized_bucket_includes_plugins_whose_categories_were_all_removed(): void + { + $emptied = Plugin::factory()->approved()->create(['categories' => []]); + Plugin::factory()->approved()->categories(PluginCategory::Media)->create(); + + Livewire::test(PluginDirectory::class) + ->set('category', PluginDirectory::CATEGORY_UNCATEGORIZED) + ->assertViewHas('plugins', fn ($plugins) => $plugins->pluck('id')->all() === [$emptied->id]); + } + public function test_unfiltered_view_does_not_hide_uncategorized_plugins(): void { - Plugin::factory()->approved()->create(['category' => null]); - Plugin::factory()->approved()->category(PluginCategory::Media)->create(); + Plugin::factory()->approved()->create(['categories' => null]); + Plugin::factory()->approved()->categories(PluginCategory::Media)->create(); Livewire::test(PluginDirectory::class) ->assertViewHas('plugins', fn ($plugins) => $plugins->count() === 2); @@ -137,16 +159,16 @@ public function test_unfiltered_view_does_not_hide_plugins_without_mobile_min_ve public function test_combining_type_category_and_mobile_version_filters(): void { - $match = Plugin::factory()->approved()->paid()->category(PluginCategory::Analytics)->create([ + $match = Plugin::factory()->approved()->paid()->categories(PluginCategory::Analytics)->create([ 'mobile_min_version' => '4.0.0', ]); - Plugin::factory()->approved()->free()->category(PluginCategory::Analytics)->create([ + Plugin::factory()->approved()->free()->categories(PluginCategory::Analytics)->create([ 'mobile_min_version' => '4.0.0', ]); - Plugin::factory()->approved()->paid()->category(PluginCategory::Media)->create([ + Plugin::factory()->approved()->paid()->categories(PluginCategory::Media)->create([ 'mobile_min_version' => '4.0.0', ]); - Plugin::factory()->approved()->paid()->category(PluginCategory::Analytics)->create([ + Plugin::factory()->approved()->paid()->categories(PluginCategory::Analytics)->create([ 'mobile_min_version' => '3.0.0', ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2ff..d99392938 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,18 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Laravel\Ai\Classification; abstract class TestCase extends BaseTestCase { - // + /** + * Jev is a paid API that queued jobs reach synchronously in tests, so it's + * always faked. Tests that care what it answers fake it again themselves. + */ + protected function setUp(): void + { + parent::setUp(); + + Classification::fake(); + } } From 46440b6328b39b23646774f4bd0136c7ca86a79e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 25 Sep 2026 20:49:42 +0100 Subject: [PATCH 2/2] Give the review-check test plugins a demo video before submitting Co-Authored-By: Claude Opus 5.5 --- .../Feature/CustomerPluginReviewChecksTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/Feature/CustomerPluginReviewChecksTest.php b/tests/Feature/CustomerPluginReviewChecksTest.php index 1f52edc3d..fc98ad5b1 100644 --- a/tests/Feature/CustomerPluginReviewChecksTest.php +++ b/tests/Feature/CustomerPluginReviewChecksTest.php @@ -18,6 +18,8 @@ class CustomerPluginReviewChecksTest extends TestCase { use RefreshDatabase; + private const DEMO_VIDEO_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + private function fakeGitHubForCreateAndSubmit(string $repoSlug): void { $base = "https://api.github.com/repos/{$repoSlug}"; @@ -101,8 +103,12 @@ public function submitting_a_plugin_for_review_runs_review_checks(): void $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']); + // Set support channel and an attested demo video (both required before submission) + $plugin->update([ + 'support_channel' => 'dev@testplugin.io', + 'demo_video_url' => self::DEMO_VIDEO_URL, + 'demo_video_attested_at' => now(), + ]); // Re-fake HTTP for the submission step $this->fakeGitHubForCreateAndSubmit($repoSlug); @@ -204,8 +210,12 @@ public function plugin_submitted_email_includes_failing_optional_checks(): void $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']); + // Set support channel and an attested demo video (both required before submission) + $plugin->update([ + 'support_channel' => 'support@bare-plugin.io', + 'demo_video_url' => self::DEMO_VIDEO_URL, + 'demo_video_attested_at' => now(), + ]); // Re-fake HTTP for submission Http::fake([