')
+ ->and($plugin->render_markup)->toContain('
{{ data.title }}
');
+});
+
+it('imports plugin with only shared.blade.php file', function (): void {
+ $user = User::factory()->create();
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => getValidSettingsYaml(),
+ 'src/shared.blade.php' => '
{{ $data["title"] }}
',
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
+
+ $pluginImportService = new PluginImportService();
+ $plugin = $pluginImportService->importFromZip($zipFile, $user);
+
+ expect($plugin)->toBeInstanceOf(Plugin::class)
+ ->and($plugin->markup_language)->toBe('blade')
+ ->and($plugin->render_markup)->toBe('
{{ $data["title"] }}
')
+ ->and($plugin->render_markup)->not->toContain('
');
+});
+
// Helper methods
function createMockZipFile(array $files): string
{
diff --git a/tests/Feature/PluginLiquidFilterTest.php b/tests/Feature/PluginLiquidFilterTest.php
index d571341..e6272c7 100644
--- a/tests/Feature/PluginLiquidFilterTest.php
+++ b/tests/Feature/PluginLiquidFilterTest.php
@@ -146,7 +146,7 @@ LIQUID
// Instead of checking for absence of 1 and 2, let's verify the count
// The filtered result should only contain 3, 4, 5
- $filteredContent = strip_tags($result);
+ $filteredContent = strip_tags((string) $result);
$this->assertStringNotContainsString('1', $filteredContent);
$this->assertStringNotContainsString('2', $filteredContent);
});
diff --git a/tests/Feature/PluginRequiredConfigurationTest.php b/tests/Feature/PluginRequiredConfigurationTest.php
index 83be449..51e1b76 100644
--- a/tests/Feature/PluginRequiredConfigurationTest.php
+++ b/tests/Feature/PluginRequiredConfigurationTest.php
@@ -268,3 +268,79 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
+
+test('hasMissingRequiredConfigurationFields returns true when required multi_string field is missing', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [], // Empty configuration
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
+});
+
+test('hasMissingRequiredConfigurationFields returns false when required multi_string field is set', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [
+ 'tags' => 'tag1, tag2, tag3', // Required field is set with comma-separated values
+ ],
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
+});
+
+test('hasMissingRequiredConfigurationFields returns true when required multi_string field is empty string', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [
+ 'tags' => '', // Empty string
+ ],
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
+});
diff --git a/tests/Feature/PluginResponseTest.php b/tests/Feature/PluginResponseTest.php
new file mode 100644
index 0000000..2a75c9e
--- /dev/null
+++ b/tests/Feature/PluginResponseTest.php
@@ -0,0 +1,287 @@
+ Http::response([
+ 'title' => 'Test Data',
+ 'items' => [
+ ['id' => 1, 'name' => 'Item 1'],
+ ['id' => 2, 'name' => 'Item 2'],
+ ],
+ ], 200, ['Content-Type' => 'application/json']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/api/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'title' => 'Test Data',
+ 'items' => [
+ ['id' => 1, 'name' => 'Item 1'],
+ ['id' => 2, 'name' => 'Item 2'],
+ ],
+ ]);
+});
+
+test('plugin parses XML responses and wraps under rss key', function (): void {
+ $xmlContent = '
+
+
+ Test RSS Feed
+ -
+ Test Item 1
+ Description 1
+
+ -
+ Test Item 2
+ Description 2
+
+
+ ';
+
+ Http::fake([
+ 'example.com/feed.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/feed.xml',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('rss');
+ expect($plugin->data_payload['rss'])->toHaveKey('@attributes');
+ expect($plugin->data_payload['rss'])->toHaveKey('channel');
+ expect($plugin->data_payload['rss']['channel']['title'])->toBe('Test RSS Feed');
+ expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2);
+});
+
+test('plugin parses JSON-parsable response body as JSON', function (): void {
+ $jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}';
+
+ Http::fake([
+ 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'title' => 'Test Data',
+ 'items' => [1, 2, 3],
+ ]);
+});
+
+test('plugin wraps plain text response body as JSON', function (): void {
+ $jsonContent = 'Lorem ipsum dolor sit amet';
+
+ Http::fake([
+ 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'data' => 'Lorem ipsum dolor sit amet',
+ ]);
+});
+
+test('plugin handles invalid XML gracefully', function (): void {
+ $invalidXml = '- unclosed tag';
+
+ Http::fake([
+ 'example.com/invalid.xml' => Http::response($invalidXml, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/invalid.xml',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe(['error' => 'Failed to parse XML response']);
+});
+
+test('plugin handles multiple URLs with mixed content types', function (): void {
+ $jsonResponse = ['title' => 'JSON Data', 'items' => [1, 2, 3]];
+ $xmlContent = '
- XML Data
';
+
+ Http::fake([
+ 'example.com/json' => Http::response($jsonResponse, 200, ['Content-Type' => 'application/json']),
+ 'example.com/xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => "https://example.com/json\nhttps://example.com/xml",
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+
+ // First URL should be JSON
+ expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse);
+
+ // Second URL should be XML wrapped under rss
+ expect($plugin->data_payload['IDX_1'])->toHaveKey('rss');
+ expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data');
+});
+
+test('plugin handles POST requests with XML responses', function (): void {
+ $xmlContent = 'successtest';
+
+ Http::fake([
+ 'example.com/api' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/api',
+ 'polling_verb' => 'post',
+ 'polling_body' => '{"query": "test"}',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('rss');
+ expect($plugin->data_payload['rss'])->toHaveKey('status');
+ expect($plugin->data_payload['rss'])->toHaveKey('data');
+ expect($plugin->data_payload['rss']['status'])->toBe('success');
+ expect($plugin->data_payload['rss']['data'])->toBe('test');
+});
+
+test('plugin parses iCal responses and filters to recent window', function (): void {
+ Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
+
+ $icalContent = <<<'ICS'
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Example Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:event-1@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250110T090000Z
+DTEND:20250110T100000Z
+SUMMARY:Past within window
+END:VEVENT
+BEGIN:VEVENT
+UID:event-2@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250301T090000Z
+DTEND:20250301T100000Z
+SUMMARY:Far future
+END:VEVENT
+BEGIN:VEVENT
+UID:event-3@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250120T090000Z
+DTEND:20250120T100000Z
+SUMMARY:Upcoming within window
+END:VEVENT
+END:VCALENDAR
+ICS;
+
+ Http::fake([
+ 'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/calendar.ics',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+ $plugin->refresh();
+
+ $ical = $plugin->data_payload['ical'];
+
+ expect($ical)->toHaveCount(2);
+ expect($ical[0]['SUMMARY'])->toBe('Past within window');
+ expect($ical[1]['SUMMARY'])->toBe('Upcoming within window');
+ expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future');
+ expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00');
+ expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00');
+
+ Carbon::setTestNow();
+});
+
+test('plugin detects iCal content without calendar content type', function (): void {
+ Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
+
+ $icalContent = <<<'ICS'
+BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+UID:event-body-detected@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250116T090000Z
+DTEND:20250116T100000Z
+SUMMARY:Detected by body
+END:VEVENT
+END:VCALENDAR
+ICS;
+
+ Http::fake([
+ 'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/calendar-body.ics',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('ical');
+ expect($plugin->data_payload['ical'])->toHaveCount(1);
+ expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body');
+ expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00');
+
+ Carbon::setTestNow();
+});
diff --git a/tests/Feature/TransformDefaultImagesTest.php b/tests/Feature/TransformDefaultImagesTest.php
index 9a27c03..2ea995f 100644
--- a/tests/Feature/TransformDefaultImagesTest.php
+++ b/tests/Feature/TransformDefaultImagesTest.php
@@ -17,7 +17,7 @@ beforeEach(function (): void {
Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content');
});
-test('command transforms default images for all device models', function () {
+test('command transforms default images for all device models', function (): void {
// Ensure we have device models
$deviceModels = DeviceModel::all();
expect($deviceModels)->not->toBeEmpty();
@@ -42,7 +42,7 @@ test('command transforms default images for all device models', function () {
}
});
-test('getDeviceSpecificDefaultImage falls back to original images for device without model', function () {
+test('getDeviceSpecificDefaultImage falls back to original images for device without model', function (): void {
$device = new Device();
$device->deviceModel = null;
@@ -53,7 +53,7 @@ test('getDeviceSpecificDefaultImage falls back to original images for device wit
expect($sleepImage)->toBe('images/sleep.bmp');
});
-test('generateDefaultScreenImage creates images from Blade templates', function () {
+test('generateDefaultScreenImage creates images from Blade templates', function (): void {
$device = Device::factory()->create();
$setupUuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo');
@@ -71,14 +71,14 @@ test('generateDefaultScreenImage creates images from Blade templates', function
expect(Storage::disk('public')->exists($sleepPath))->toBeTrue();
})->skipOnCI();
-test('generateDefaultScreenImage throws exception for invalid image type', function () {
+test('generateDefaultScreenImage throws exception for invalid image type', function (): void {
$device = Device::factory()->create();
- expect(fn () => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type'))
+ expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type'))
->toThrow(InvalidArgumentException::class);
});
-test('getDeviceSpecificDefaultImage returns null for invalid image type', function () {
+test('getDeviceSpecificDefaultImage returns null for invalid image type', function (): void {
$device = new Device();
$device->deviceModel = DeviceModel::first();
diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php
new file mode 100644
index 0000000..a80c63a
--- /dev/null
+++ b/tests/Feature/Volt/CatalogTrmnlTest.php
@@ -0,0 +1,286 @@
+ Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->assertSee('Install')
+ ->assertDontSeeHtml('variant="subtle" icon="eye"')
+ ->assertSee('Installs: 10');
+});
+
+it('shows preview button when screenshot_url is provided', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/screenshot.png',
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->assertSee('Preview');
+});
+
+it('searches TRMNL recipes when search term is provided', function (): void {
+ Http::fake([
+ // First call (mount -> newest)
+ 'usetrmnl.com/recipes.json?*' => Http::sequence()
+ ->push([
+ 'data' => [
+ [
+ 'id' => 1,
+ 'name' => 'Initial Recipe',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 1, 'forks' => 0],
+ ],
+ ],
+ ], 200)
+ // Second call (search)
+ ->push([
+ 'data' => [
+ [
+ 'id' => 2,
+ 'name' => 'Weather Search Result',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 3, 'forks' => 1],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Initial Recipe')
+ ->set('search', 'weather')
+ ->assertSee('Weather Search Result')
+ ->assertSee('Install');
+});
+
+it('installs plugin successfully when user is authenticated', function (): void {
+ $user = User::factory()->create();
+
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
+});
+
+it('shows error when user is not authenticated', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertStatus(403); // This will return 403 because user is not authenticated
+});
+
+it('shows error when plugin installation fails', function (): void {
+ $user = User::factory()->create();
+
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
+});
+
+it('previews a recipe with async fetch', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/old.png',
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/recipes/123.json' => Http::response([
+ 'data' => [
+ 'id' => 123,
+ 'name' => 'Weather Chum Updated',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/new.png',
+ 'author_bio' => ['description' => 'New bio'],
+ 'stats' => ['installs' => 11, 'forks' => 3],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('previewRecipe', '123')
+ ->assertSet('previewingRecipe', '123')
+ ->assertSet('previewData.name', 'Weather Chum Updated')
+ ->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
+ ->assertSee('Preview Weather Chum Updated')
+ ->assertSee('New bio');
+});
+
+it('supports pagination and loading more recipes', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
+ 'data' => [
+ [
+ 'id' => 1,
+ 'name' => 'Recipe Page 1',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 1, 'forks' => 0],
+ ],
+ ],
+ 'next_page_url' => '/recipes.json?page=2',
+ ], 200),
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
+ 'data' => [
+ [
+ 'id' => 2,
+ 'name' => 'Recipe Page 2',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 2, 'forks' => 0],
+ ],
+ ],
+ 'next_page_url' => null,
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Recipe Page 1')
+ ->assertDontSee('Recipe Page 2')
+ ->assertSee('Load next page')
+ ->call('loadMore')
+ ->assertSee('Recipe Page 1')
+ ->assertSee('Recipe Page 2')
+ ->assertDontSee('Load next page');
+});
+
+it('resets pagination when search term changes', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
+ ->push([
+ 'data' => [['id' => 1, 'name' => 'Initial 1']],
+ 'next_page_url' => '/recipes.json?page=2',
+ ])
+ ->push([
+ 'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
+ 'next_page_url' => null,
+ ]),
+ 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
+ 'data' => [['id' => 2, 'name' => 'Weather Result']],
+ 'next_page_url' => null,
+ ]),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Initial 1')
+ ->call('loadMore')
+ ->set('search', 'weather')
+ ->assertSee('Weather Result')
+ ->assertDontSee('Initial 1')
+ ->assertSet('page', 1);
+});
diff --git a/tests/Feature/Volt/DevicePalettesTest.php b/tests/Feature/Volt/DevicePalettesTest.php
new file mode 100644
index 0000000..376a4a6
--- /dev/null
+++ b/tests/Feature/Volt/DevicePalettesTest.php
@@ -0,0 +1,575 @@
+create();
+
+ $this->actingAs($user);
+
+ $this->get(route('device-palettes.index'))->assertOk();
+});
+
+test('component loads all device palettes on mount', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index');
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 3);
+});
+
+test('can open modal to create new device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal');
+
+ $component
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('viewingDevicePaletteId', null)
+ ->assertSet('name', null)
+ ->assertSet('grays', 2);
+});
+
+test('can create a new device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('description', 'Test Palette Description')
+ ->set('grays', 16)
+ ->set('colors', ['#FF0000', '#00FF00'])
+ ->set('framework_class', 'TestFramework')
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->description)->toBe('Test Palette Description');
+ expect($palette->grays)->toBe(16);
+ expect($palette->colors)->toBe(['#FF0000', '#00FF00']);
+ expect($palette->framework_class)->toBe('TestFramework');
+ expect($palette->source)->toBe('manual');
+});
+
+test('can create a grayscale-only palette without colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'grayscale-palette')
+ ->set('grays', 256)
+ ->set('colors', [])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'grayscale-palette')->first();
+ expect($palette->colors)->toBeNull();
+ expect($palette->grays)->toBe(256);
+});
+
+test('can open modal to edit existing device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'existing-palette',
+ 'description' => 'Existing Description',
+ 'grays' => 4,
+ 'colors' => ['#FF0000', '#00FF00'],
+ 'framework_class' => 'Framework',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component
+ ->assertSet('editingDevicePaletteId', $palette->id)
+ ->assertSet('name', 'existing-palette')
+ ->assertSet('description', 'Existing Description')
+ ->assertSet('grays', 4)
+ ->assertSet('colors', ['#FF0000', '#00FF00'])
+ ->assertSet('framework_class', 'Framework');
+});
+
+test('can update an existing device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id)
+ ->set('name', 'updated-palette')
+ ->set('description', 'Updated Description')
+ ->set('grays', 16)
+ ->set('colors', ['#0000FF'])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette->refresh();
+ expect($palette->name)->toBe('updated-palette');
+ expect($palette->description)->toBe('Updated Description');
+ expect($palette->grays)->toBe(16);
+ expect($palette->colors)->toBe(['#0000FF']);
+});
+
+test('can delete a device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'to-delete',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('deleteDevicePalette', $palette->id);
+
+ expect(DevicePalette::find($palette->id))->toBeNull();
+ $component->assertSet('devicePalettes', function ($palettes) use ($palette) {
+ return $palettes->where('id', $palette->id)->isEmpty();
+ });
+});
+
+test('can duplicate a device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-palette',
+ 'description' => 'Original Description',
+ 'grays' => 4,
+ 'colors' => ['#FF0000', '#00FF00'],
+ 'framework_class' => 'Framework',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('duplicateDevicePalette', $palette->id);
+
+ $component
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('name', 'original-palette (Copy)')
+ ->assertSet('description', 'Original Description')
+ ->assertSet('grays', 4)
+ ->assertSet('colors', ['#FF0000', '#00FF00'])
+ ->assertSet('framework_class', 'Framework');
+});
+
+test('can add a color to the colors array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component
+ ->assertHasNoErrors()
+ ->assertSet('colors', ['#FF0000'])
+ ->assertSet('colorInput', '');
+});
+
+test('cannot add duplicate colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000'])
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component
+ ->assertHasNoErrors()
+ ->assertSet('colors', ['#FF0000']);
+});
+
+test('can add multiple colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor')
+ ->set('colorInput', '#00FF00')
+ ->call('addColor')
+ ->set('colorInput', '#0000FF')
+ ->call('addColor');
+
+ $component
+ ->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']);
+});
+
+test('can remove a color from the colors array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
+ ->call('removeColor', 1);
+
+ $component->assertSet('colors', ['#FF0000', '#0000FF']);
+});
+
+test('removing color reindexes array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
+ ->call('removeColor', 0);
+
+ $colors = $component->get('colors');
+ expect($colors)->toBe(['#00FF00', '#0000FF']);
+ expect(array_keys($colors))->toBe([0, 1]);
+});
+
+test('can open modal in view-only mode for api-sourced palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'api-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'api',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id, true);
+
+ $component
+ ->assertSet('viewingDevicePaletteId', $palette->id)
+ ->assertSet('editingDevicePaletteId', null);
+});
+
+test('name is required when creating device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['name']);
+});
+
+test('name must be unique when creating device palette', function (): void {
+ $user = User::factory()->create();
+ DevicePalette::create([
+ 'name' => 'existing-name',
+ 'grays' => 2,
+ 'framework_class' => '',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'existing-name')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['name']);
+});
+
+test('name can be same when updating device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-name',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id)
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+});
+
+test('grays is required when creating device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', null)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('grays must be at least 1', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 0)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('grays must be at most 256', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 257)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('colors must be valid hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('colors', ['invalid-color', '#FF0000'])
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['colors.0']);
+});
+
+test('color input must be valid hex format when adding color', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', 'invalid-color')
+ ->call('addColor');
+
+ $component->assertHasErrors(['colorInput']);
+});
+
+test('color input accepts valid hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component->assertHasNoErrors();
+});
+
+test('color input accepts lowercase hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#ff0000')
+ ->call('addColor');
+
+ $component->assertHasNoErrors();
+});
+
+test('description can be null', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('description', null)
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->description)->toBeNull();
+});
+
+test('framework class can be empty string', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('framework_class', '')
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->framework_class)->toBe('');
+});
+
+test('empty colors array is saved as null', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('colors', [])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->colors)->toBeNull();
+});
+
+test('component resets form after saving', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('description', 'Test Description')
+ ->set('grays', 16)
+ ->set('colors', ['#FF0000'])
+ ->set('framework_class', 'TestFramework')
+ ->call('saveDevicePalette');
+
+ $component
+ ->assertSet('name', null)
+ ->assertSet('description', null)
+ ->assertSet('grays', 2)
+ ->assertSet('colors', [])
+ ->assertSet('framework_class', '')
+ ->assertSet('colorInput', '')
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('viewingDevicePaletteId', null);
+});
+
+test('component handles palette with null colors when editing', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'grayscale-palette',
+ 'grays' => 2,
+ 'colors' => null,
+ 'framework_class' => '',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component->assertSet('colors', []);
+});
+
+test('component handles palette with string colors when editing', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'string-colors-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ ]);
+ // Manually set colors as JSON string to simulate edge case
+ $palette->setRawAttributes(array_merge($palette->getAttributes(), [
+ 'colors' => json_encode(['#FF0000', '#00FF00']),
+ ]));
+ $palette->save();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component->assertSet('colors', ['#FF0000', '#00FF00']);
+});
+
+test('component refreshes palette list after creating', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'new-palette')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 3);
+ expect(DevicePalette::count())->toBe($initialCount + 3);
+});
+
+test('component refreshes palette list after deleting', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ $palette1 = DevicePalette::create([
+ 'name' => 'palette-1',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+ $palette2 = DevicePalette::create([
+ 'name' => 'palette-2',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('deleteDevicePalette', $palette1->id);
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 1);
+ expect(DevicePalette::count())->toBe($initialCount + 1);
+});
diff --git a/tests/Unit/Liquid/Filters/DateTest.php b/tests/Unit/Liquid/Filters/DateTest.php
index d967951..7de8949 100644
--- a/tests/Unit/Liquid/Filters/DateTest.php
+++ b/tests/Unit/Liquid/Filters/DateTest.php
@@ -30,3 +30,65 @@ test('days_ago filter with large number works correctly', function (): void {
expect($filter->days_ago(100))->toBe($hundredDaysAgo);
});
+
+test('ordinalize filter formats date with ordinal day', function (): void {
+ $filter = new Date();
+
+ expect($filter->ordinalize('2025-10-02', '%A, %B <>, %Y'))
+ ->toBe('Thursday, October 2nd, 2025');
+});
+
+test('ordinalize filter handles datetime string with timezone', function (): void {
+ $filter = new Date();
+
+ expect($filter->ordinalize('2025-12-31 16:50:38 -0400', '%A, %b <>'))
+ ->toBe('Wednesday, Dec 31st');
+});
+
+test('ordinalize filter handles different ordinal suffixes', function (): void {
+ $filter = new Date();
+
+ // 1st
+ expect($filter->ordinalize('2025-01-01', '<>'))
+ ->toBe('1st');
+
+ // 2nd
+ expect($filter->ordinalize('2025-01-02', '<>'))
+ ->toBe('2nd');
+
+ // 3rd
+ expect($filter->ordinalize('2025-01-03', '<>'))
+ ->toBe('3rd');
+
+ // 4th
+ expect($filter->ordinalize('2025-01-04', '<>'))
+ ->toBe('4th');
+
+ // 11th (special case)
+ expect($filter->ordinalize('2025-01-11', '<>'))
+ ->toBe('11th');
+
+ // 12th (special case)
+ expect($filter->ordinalize('2025-01-12', '<>'))
+ ->toBe('12th');
+
+ // 13th (special case)
+ expect($filter->ordinalize('2025-01-13', '<>'))
+ ->toBe('13th');
+
+ // 21st
+ expect($filter->ordinalize('2025-01-21', '<>'))
+ ->toBe('21st');
+
+ // 22nd
+ expect($filter->ordinalize('2025-01-22', '<>'))
+ ->toBe('22nd');
+
+ // 23rd
+ expect($filter->ordinalize('2025-01-23', '<>'))
+ ->toBe('23rd');
+
+ // 24th
+ expect($filter->ordinalize('2025-01-24', '<>'))
+ ->toBe('24th');
+});
diff --git a/tests/Unit/Liquid/Filters/LocalizationTest.php b/tests/Unit/Liquid/Filters/LocalizationTest.php
index a52623f..3129b1e 100644
--- a/tests/Unit/Liquid/Filters/LocalizationTest.php
+++ b/tests/Unit/Liquid/Filters/LocalizationTest.php
@@ -77,7 +77,7 @@ test('l_date handles null locale parameter', function (): void {
$filter = new Localization();
$date = '2025-01-11';
- $result = $filter->l_date($date, 'Y-m-d', null);
+ $result = $filter->l_date($date, 'Y-m-d');
// Should work the same as default
expect($result)->toContain('2025');
diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php
index ef054b1..aa9a28e 100644
--- a/tests/Unit/Models/PluginTest.php
+++ b/tests/Unit/Models/PluginTest.php
@@ -1,6 +1,8 @@
data_payload['IDX_2'])->toBe(['headline' => 'test']);
});
+test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void {
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ // empty lines and extra spaces between the URL to generate empty entries
+ 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ",
+ 'polling_verb' => 'get',
+ ]);
+
+ // Mock only the valid URLs
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200),
+ 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200),
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // payload should only have 2 items, and they should be indexed 0 and 1
+ expect($plugin->data_payload)->toHaveCount(2);
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+
+ // data is correct
+ expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']);
+
+ // no empty index exists
+ expect($plugin->data_payload)->not->toHaveKey('IDX_2');
+});
+
test('updateDataPayload handles single URL without nesting', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
@@ -357,3 +388,553 @@ test('resolveLiquidVariables handles empty configuration', function (): void {
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
+
+test('resolveLiquidVariables uses external renderer when preferred_renderer is trmnl-liquid and template contains for loop', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'https://api1.example.com/data\nhttps://api2.example.com/data',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ $result = $plugin->resolveLiquidVariables($template);
+
+ // Trim trailing newlines that may be added by the process
+ expect(mb_trim($result))->toBe('https://api1.example.com/data\nhttps://api2.example.com/data');
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli') &&
+ str_contains($command, '--template') &&
+ str_contains($command, '--context');
+ });
+});
+
+test('resolveLiquidVariables uses internal renderer when preferred_renderer is not trmnl-liquid', function (): void {
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'php',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ // Should use internal renderer even with for loop
+ $result = $plugin->resolveLiquidVariables($template);
+
+ // Internal renderer should process the template
+ expect($result)->toBeString();
+});
+
+test('resolveLiquidVariables uses internal renderer when external renderer is disabled', function (): void {
+ config(['services.trmnl.liquid_enabled' => false]);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ // Should use internal renderer when external is disabled
+ $result = $plugin->resolveLiquidVariables($template);
+
+ expect($result)->toBeString();
+});
+
+test('resolveLiquidVariables uses internal renderer when template does not contain for loop', function (): void {
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'api_key' => 'test123',
+ ],
+ ]);
+
+ $template = 'https://api.example.com/data?key={{ api_key }}';
+
+ // Should use internal renderer when no for loop
+ $result = $plugin->resolveLiquidVariables($template);
+
+ expect($result)->toBe('https://api.example.com/data?key=test123');
+
+ Illuminate\Support\Facades\Process::assertNothingRan();
+});
+
+test('resolveLiquidVariables detects for loop with standard opening tag', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'resolved',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [],
+ ]);
+
+ // Test {% for pattern
+ $template = '{% for item in items %}test{% endfor %}';
+ $plugin->resolveLiquidVariables($template);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('resolveLiquidVariables detects for loop with whitespace stripping tag', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'resolved',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [],
+ ]);
+
+ // Test {%- for pattern (with whitespace stripping)
+ $template = '{%- for item in items %}test{% endfor %}';
+ $plugin->resolveLiquidVariables($template);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('updateDataPayload resolves entire polling_url field first then splits by newline', function (): void {
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
+ 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/data",
+ 'polling_verb' => 'get',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // Should have split the multi-line URL and generated two requests
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+ expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
+});
+
+test('updateDataPayload handles multi-line polling_url with for loop using external renderer', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: "https://api1.example.com/data\nhttps://api2.example.com/data",
+ exitCode: 0
+ ),
+ ]);
+
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
+ 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'polling_url' => <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID
+ ,
+ 'polling_verb' => 'get',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // Should have used external renderer and generated two URLs
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+ expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('plugin render uses user timezone when set', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/New_York',
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)->toContain('America/New_York');
+});
+
+test('plugin render falls back to app timezone when user timezone is not set', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => null,
+ ]);
+
+ config(['app.timezone' => 'Europe/London']);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)->toContain('Europe/London');
+});
+
+test('plugin render calculates correct UTC offset from user timezone', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ // America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
+ $expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
+ expect($rendered)->toContain($expectedOffset);
+});
+
+test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => null,
+ ]);
+
+ config(['app.timezone' => 'Europe/London']);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ // Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
+ $expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
+ expect($rendered)->toContain($expectedOffset);
+});
+
+test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)
+ ->toContain('America/Chicago')
+ ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
+});
+
+/**
+ * Plugin security: XSS Payload Dataset
+ * [Input, Expected Result, Forbidden String]
+ */
+dataset('xss_vectors', [
+ 'standard_script' => ['Safe ', 'Safe ', '