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
+
+
+
+
+
+
+
+
+
Delete {{ $plugin->name }}?
+
This will also remove this instance from your playlists.
+
+
+
+
+
+ Cancel
+
+ Delete instance
+
+
+
+
+
+
+
+
+ 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())
+
) }})
+ @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
+
+
+
+
+
+
+ @if(empty($instances))
+
+
+ No instances yet
+ Create your first Image Webhook instance to get started.
+
+
+ @else
+
+
+
+ |
+ Name
+ |
+
+ Actions
+ |
+
+
+
+
+ @foreach($instances as $instance)
+
+ |
+ {{ $instance['name'] }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ @endforeach
+
+
+ @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 ', '