From 3def60ae3ef70c42ff7f98499028d152fa960c57 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 5 Jan 2026 18:09:39 +0100 Subject: [PATCH 1/2] feat: add Image Webhook plugin --- app/Models/Plugin.php | 11 + app/Services/ImageGenerationService.php | 4 + database/factories/PluginFactory.php | 16 + ...53321_add_plugin_type_to_plugins_table.php | 28 ++ .../plugins/image-webhook-instance.blade.php | 298 ++++++++++++++++++ .../livewire/plugins/image-webhook.blade.php | 163 ++++++++++ .../views/livewire/plugins/index.blade.php | 9 +- routes/api.php | 85 +++++ routes/web.php | 2 + tests/Feature/Api/ImageWebhookTest.php | 196 ++++++++++++ tests/Unit/Models/PluginTest.php | 12 +- 11 files changed, 817 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php create mode 100644 resources/views/livewire/plugins/image-webhook-instance.blade.php create mode 100644 resources/views/livewire/plugins/image-webhook.blade.php create mode 100644 tests/Feature/Api/ImageWebhookTest.php diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 6f5d88b..c4b45c8 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; +use App\Services\ImageGenerationService; use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\PluginImportService; use Carbon\Carbon; @@ -44,6 +45,7 @@ class Plugin extends Model 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', + 'plugin_type' => 'string', ]; protected static function boot() @@ -133,6 +135,11 @@ class Plugin extends Model public function isDataStale(): bool { + // Image webhook plugins don't use data staleness - images are pushed directly + if ($this->plugin_type === 'image_webhook') { + return false; + } + if ($this->data_strategy === 'webhook') { // Treat as stale if any webhook event has occurred in the past hour return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour()); @@ -447,6 +454,10 @@ class Plugin extends Model */ public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string { + if ($this->plugin_type !== 'recipe') { + throw new \InvalidArgumentException('Render method is only applicable for recipe plugins.'); + } + if ($this->render_markup) { $renderedContent = ''; diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index fcd5f12..b8269a3 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -280,6 +280,10 @@ class ImageGenerationService public static function resetIfNotCacheable(?Plugin $plugin): void { if ($plugin?->id) { + // Image webhook plugins have finalized images that shouldn't be reset + if ($plugin->plugin_type === 'image_webhook') { + return; + } // Check if any devices have custom dimensions or use non-standard DeviceModels $hasCustomDimensions = Device::query() ->where(function ($query): void { diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index a2d2e65..10a1580 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -29,8 +29,24 @@ class PluginFactory extends Factory 'icon_url' => null, 'flux_icon_name' => null, 'author_name' => $this->faker->name(), + 'plugin_type' => 'recipe', 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; } + + /** + * Indicate that the plugin is an image webhook plugin. + */ + public function imageWebhook(): static + { + return $this->state(fn (array $attributes): array => [ + 'plugin_type' => 'image_webhook', + 'data_strategy' => 'static', + 'data_stale_minutes' => 60, + 'polling_url' => null, + 'polling_verb' => 'get', + 'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']), + ]); + } } diff --git a/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php new file mode 100644 index 0000000..558fe2c --- /dev/null +++ b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php @@ -0,0 +1,28 @@ +string('plugin_type')->default('recipe')->after('uuid'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table): void { + $table->dropColumn('plugin_type'); + }); + } +}; diff --git a/resources/views/livewire/plugins/image-webhook-instance.blade.php b/resources/views/livewire/plugins/image-webhook-instance.blade.php new file mode 100644 index 0000000..e4ad9df --- /dev/null +++ b/resources/views/livewire/plugins/image-webhook-instance.blade.php @@ -0,0 +1,298 @@ +user()->plugins->contains($this->plugin), 403); + abort_unless($this->plugin->plugin_type === 'image_webhook', 404); + + $this->name = $this->plugin->name; + } + + protected array $rules = [ + 'name' => 'required|string|max:255', + 'checked_devices' => 'array', + 'device_playlist_names' => 'array', + 'device_playlists' => 'array', + 'device_weekdays' => 'array', + 'device_active_from' => 'array', + 'device_active_until' => 'array', + ]; + + public function updateName(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->validate(['name' => 'required|string|max:255']); + $this->plugin->update(['name' => $this->name]); + } + + + public function addToPlaylist() + { + $this->validate([ + 'checked_devices' => 'required|array|min:1', + ]); + + foreach ($this->checked_devices as $deviceId) { + if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) { + $this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.'); + return; + } + + if ($this->device_playlists[$deviceId] === 'new') { + if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) { + $this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.'); + return; + } + } + } + + foreach ($this->checked_devices as $deviceId) { + $playlist = null; + + if ($this->device_playlists[$deviceId] === 'new') { + $playlist = \App\Models\Playlist::create([ + 'device_id' => $deviceId, + 'name' => $this->device_playlist_names[$deviceId], + 'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null, + 'active_from' => $this->device_active_from[$deviceId] ?? null, + 'active_until' => $this->device_active_until[$deviceId] ?? null, + ]); + } else { + $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]); + } + + $maxOrder = $playlist->items()->max('order') ?? 0; + + // Image webhook plugins only support full layout + $playlist->items()->create([ + 'plugin_id' => $this->plugin->id, + 'order' => $maxOrder + 1, + ]); + } + + $this->reset([ + 'checked_devices', + 'device_playlists', + 'device_playlist_names', + 'device_weekdays', + 'device_active_from', + 'device_active_until', + ]); + Flux::modal('add-to-playlist')->close(); + } + + public function getDevicePlaylists($deviceId) + { + return \App\Models\Playlist::where('device_id', $deviceId)->get(); + } + + public function hasAnyPlaylistSelected(): bool + { + foreach ($this->checked_devices as $deviceId) { + if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) { + return true; + } + } + return false; + } + + public function deletePlugin(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->plugin->delete(); + $this->redirect(route('plugins.image-webhook')); + } + + public function getImagePath(): ?string + { + if (!$this->plugin->current_image) { + return null; + } + + $extensions = ['png', 'bmp']; + foreach ($extensions as $ext) { + $path = 'images/generated/'.$this->plugin->current_image.'.'.$ext; + if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) { + return $path; + } + } + + return null; + } +}; +?> + +
+
+
+

Image Webhook – {{$plugin->name}}

+ + + + Add to Playlist + + + + + + + Delete Instance + + + + +
+ + +
+
+ Add to Playlist +
+ +
+ +
+ + @foreach(auth()->user()->devices as $device) + + @endforeach + +
+ + @if(count($checked_devices) > 0) + +
+ @foreach($checked_devices as $deviceId) + @php + $device = auth()->user()->devices->find($deviceId); + @endphp +
+
+ {{ $device->name }} +
+ +
+ + + @foreach($this->getDevicePlaylists($deviceId) as $playlist) + + @endforeach + + +
+ + @if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new') +
+
+ +
+
+ + + + + + + + + +
+
+
+ +
+
+ +
+
+
+ @endif +
+ @endforeach +
+ @endif + + +
+ + Add to Playlist +
+ +
+
+ + +
+ Delete {{ $plugin->name }}? +

This will also remove this instance from your playlists.

+
+ +
+ + + Cancel + + Delete instance +
+
+ +
+
+
+
+ +
+ +
+ + Save +
+
+ +
+ Webhook URL + + POST an image (PNG or BMP) to this URL to update the displayed image. + + + Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown. + + +
+
+ +
+
+ Current Image + @if($this->getImagePath()) + {{ $plugin->name }} + @else + + No image uploaded yet. POST an image to the webhook URL to get started. + + @endif +
+
+
+
+
+ diff --git a/resources/views/livewire/plugins/image-webhook.blade.php b/resources/views/livewire/plugins/image-webhook.blade.php new file mode 100644 index 0000000..3161443 --- /dev/null +++ b/resources/views/livewire/plugins/image-webhook.blade.php @@ -0,0 +1,163 @@ + 'required|string|max:255', + ]; + + public function mount(): void + { + $this->refreshInstances(); + } + + public function refreshInstances(): void + { + $this->instances = auth()->user() + ->plugins() + ->where('plugin_type', 'image_webhook') + ->orderBy('created_at', 'desc') + ->get() + ->toArray(); + } + + public function createInstance(): void + { + abort_unless(auth()->user() !== null, 403); + $this->validate(); + + Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => auth()->id(), + 'name' => $this->name, + 'plugin_type' => 'image_webhook', + 'data_strategy' => 'static', // Not used for image_webhook, but required + 'data_stale_minutes' => 60, // Not used for image_webhook, but required + ]); + + $this->reset(['name']); + $this->refreshInstances(); + + Flux::modal('create-instance')->close(); + } + + public function deleteInstance(int $pluginId): void + { + abort_unless(auth()->user() !== null, 403); + + $plugin = Plugin::where('id', $pluginId) + ->where('user_id', auth()->id()) + ->where('plugin_type', 'image_webhook') + ->firstOrFail(); + + $plugin->delete(); + $this->refreshInstances(); + } +}; +?> + +
+
+
+

Image Webhook + Plugin +

+ + Create Instance + +
+ + +
+
+ Create Image Webhook Instance + Create a new instance that accepts images via webhook +
+ +
+
+ +
+ +
+ + Create Instance +
+
+
+
+ + @if(empty($instances)) +
+ + No instances yet + Create your first Image Webhook instance to get started. + +
+ @else + + + + + + + + + + @foreach($instances as $instance) + + + + + @endforeach + +
+
Name
+
+
Actions
+
+ {{ $instance['name'] }} + +
+ + + + + + + + +
+
+ @endif + + @foreach($instances as $instance) + +
+ Delete {{ $instance['name'] }}? +

This will also remove this instance from your playlists.

+
+ +
+ + + Cancel + + Delete instance +
+
+ @endforeach +
+
+ diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 469365c..4347aaf 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -26,6 +26,8 @@ new class extends Component { ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'], 'api' => ['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'], + 'image-webhook' => + ['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'], ]; protected $rules = [ @@ -40,7 +42,12 @@ new class extends Component { public function refreshPlugins(): void { - $userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray(); + // Only show recipe plugins in the main list (image_webhook has its own management page) + $userPlugins = auth()->user()?->plugins() + ->where('plugin_type', 'recipe') + ->get() + ->makeHidden(['render_markup', 'data_payload']) + ->toArray(); $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); $allPlugins = array_values($allPlugins); $allPlugins = $this->sortPlugins($allPlugins); diff --git a/routes/api.php b/routes/api.php index b1d08b4..5700a43 100644 --- a/routes/api.php +++ b/routes/api.php @@ -549,6 +549,91 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) { return response()->json(['message' => 'Data updated successfully']); })->name('api.custom_plugins.webhook'); +Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) { + $plugin = Plugin::where('uuid', $uuid)->firstOrFail(); + + // Check if plugin is image_webhook type + if ($plugin->plugin_type !== 'image_webhook') { + return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400); + } + + // Accept image from either multipart form or raw binary + $image = null; + $extension = null; + + if ($request->hasFile('image')) { + $file = $request->file('image'); + $extension = mb_strtolower($file->getClientOriginalExtension()); + $image = $file->get(); + } elseif ($request->has('image')) { + // Base64 encoded image + $imageData = $request->input('image'); + if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) { + $extension = mb_strtolower($matches[1]); + $image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1)); + } else { + return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400); + } + } else { + // Try raw binary + $image = $request->getContent(); + $contentType = $request->header('Content-Type', ''); + $trimmedContent = mb_trim($image); + + // Check if content is empty or just empty JSON + if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') { + return response()->json(['error' => 'No image data provided'], 400); + } + + // If it's a JSON request without image field, return error + if (str_contains($contentType, 'application/json')) { + return response()->json(['error' => 'No image data provided'], 400); + } + + // Detect image type from content + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_buffer($finfo, $image); + finfo_close($finfo); + + $extension = match ($mimeType) { + 'image/png' => 'png', + 'image/bmp' => 'bmp', + default => null, + }; + + if (! $extension) { + return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400); + } + } + + // Validate extension + $allowedExtensions = ['png', 'bmp']; + if (! in_array($extension, $allowedExtensions)) { + return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400); + } + + // Generate a new UUID for each image upload to prevent device caching + $imageUuid = \Illuminate\Support\Str::uuid()->toString(); + $filename = $imageUuid.'.'.$extension; + $path = 'images/generated/'.$filename; + + // Save image to storage + Storage::disk('public')->put($path, $image); + + // Update plugin's current_image field with the new UUID + $plugin->update([ + 'current_image' => $imageUuid, + ]); + + // Clean up old images + ImageGenerationService::cleanupFolder(); + + return response()->json([ + 'message' => 'Image uploaded successfully', + 'image_url' => url('storage/'.$path), + ]); +})->name('api.plugin_settings.image'); + Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) { if (! $trmnlp_id || mb_trim($trmnlp_id) === '') { return response()->json([ diff --git a/routes/web.php b/routes/web.php index 7b7868d..b3069bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,6 +31,8 @@ Route::middleware(['auth'])->group(function () { Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe'); Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup'); Volt::route('plugins/api', 'plugins.api')->name('plugins.api'); + Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook'); + Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance'); Volt::route('playlists', 'playlists.index')->name('playlists.index'); Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) { diff --git a/tests/Feature/Api/ImageWebhookTest.php b/tests/Feature/Api/ImageWebhookTest.php new file mode 100644 index 0000000..121f90a --- /dev/null +++ b/tests/Feature/Api/ImageWebhookTest.php @@ -0,0 +1,196 @@ +makeDirectory('/images/generated'); +}); + +test('can upload image to image webhook plugin via multipart form', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + $image = UploadedFile::fake()->image('test.png', 800, 480); + + $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [ + 'image' => $image, + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'message', + 'image_url', + ]); + + $plugin->refresh(); + expect($plugin->current_image) + ->not->toBeNull() + ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID + + // File should exist with the new UUID + Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); + + // Image URL should contain the new UUID + expect($response->json('image_url')) + ->toContain($plugin->current_image); +}); + +test('can upload image to image webhook plugin via raw binary', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + // Create a simple PNG image binary + $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + + $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ + 'CONTENT_TYPE' => 'image/png', + ], $pngData); + + $response->assertOk() + ->assertJsonStructure([ + 'message', + 'image_url', + ]); + + $plugin->refresh(); + expect($plugin->current_image) + ->not->toBeNull() + ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID + + // File should exist with the new UUID + Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); + + // Image URL should contain the new UUID + expect($response->json('image_url')) + ->toContain($plugin->current_image); +}); + +test('can upload image to image webhook plugin via base64 data URI', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + // Create a simple PNG image as base64 data URI + $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [ + 'image' => $base64Image, + ]); + + $response->assertOk() + ->assertJsonStructure([ + 'message', + 'image_url', + ]); + + $plugin->refresh(); + expect($plugin->current_image) + ->not->toBeNull() + ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID + + // File should exist with the new UUID + Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); + + // Image URL should contain the new UUID + expect($response->json('image_url')) + ->toContain($plugin->current_image); +}); + +test('returns 400 for non-image-webhook plugin', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'plugin_type' => 'recipe', + ]); + + $image = UploadedFile::fake()->image('test.png', 800, 480); + + $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [ + 'image' => $image, + ]); + + $response->assertStatus(400) + ->assertJson(['error' => 'Plugin is not an image webhook plugin']); +}); + +test('returns 404 for non-existent plugin', function (): void { + $image = UploadedFile::fake()->image('test.png', 800, 480); + + $response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [ + 'image' => $image, + ]); + + $response->assertNotFound(); +}); + +test('returns 400 for unsupported image format', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + // Create a fake GIF file (not supported) + $gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); + + $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ + 'CONTENT_TYPE' => 'image/gif', + ], $gifData); + + $response->assertStatus(400) + ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']); +}); + +test('returns 400 for JPG image format', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + // Create a fake JPG file (not supported) + $jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A'); + + $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ + 'CONTENT_TYPE' => 'image/jpeg', + ], $jpgData); + + $response->assertStatus(400) + ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']); +}); + +test('returns 400 when no image data provided', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->imageWebhook()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []); + + $response->assertStatus(400) + ->assertJson(['error' => 'No image data provided']); +}); + +test('image webhook plugin isDataStale returns false', function (): void { + $plugin = Plugin::factory()->imageWebhook()->create(); + + expect($plugin->isDataStale())->toBeFalse(); +}); + +test('image webhook plugin factory creates correct plugin type', function (): void { + $plugin = Plugin::factory()->imageWebhook()->create(); + + expect($plugin) + ->plugin_type->toBe('image_webhook') + ->data_strategy->toBe('static'); +}); diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index b42668d..0847e36 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -685,11 +685,11 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context * [Input, Expected Result, Forbidden String] */ dataset('xss_vectors', [ - 'standard_script' => ['Safe ', 'Safe ', '', 'Safe ', '