mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
add error handling for preview fix idx bug and add tests fix light mode styling and remove transitions add global styling class
940 lines
32 KiB
PHP
940 lines
32 KiB
PHP
<?php
|
|
|
|
use App\Models\Plugin;
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
test('plugin has required attributes', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'name' => 'Test Plugin',
|
|
'data_payload' => ['key' => 'value'],
|
|
]);
|
|
|
|
expect($plugin)
|
|
->name->toBe('Test Plugin')
|
|
->data_payload->toBe(['key' => 'value'])
|
|
->uuid->toBeString()
|
|
->uuid->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/');
|
|
});
|
|
|
|
test('plugin automatically generates uuid on creation', function (): void {
|
|
$plugin = Plugin::factory()->create();
|
|
|
|
expect($plugin->uuid)
|
|
->toBeString()
|
|
->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/');
|
|
});
|
|
|
|
test('plugin can have custom uuid', function (): void {
|
|
$uuid = Illuminate\Support\Str::uuid();
|
|
$plugin = Plugin::factory()->create(['uuid' => $uuid]);
|
|
|
|
expect($plugin->uuid)->toBe($uuid);
|
|
});
|
|
|
|
test('plugin data_payload is cast to array', function (): void {
|
|
$data = ['key' => 'value'];
|
|
$plugin = Plugin::factory()->create(['data_payload' => $data]);
|
|
|
|
expect($plugin->data_payload)
|
|
->toBeArray()
|
|
->toBe($data);
|
|
});
|
|
|
|
test('plugin can have polling body for POST requests', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'polling_verb' => 'post',
|
|
'polling_body' => '{"query": "query { user { id name } }"}',
|
|
]);
|
|
|
|
expect($plugin->polling_body)->toBe('{"query": "query { user { id name } }"}');
|
|
});
|
|
|
|
test('updateDataPayload sends POST request with body when polling_verb is post', function (): void {
|
|
Http::fake([
|
|
'https://example.com/api' => Http::response(['success' => true], 200),
|
|
]);
|
|
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'polling',
|
|
'polling_url' => 'https://example.com/api',
|
|
'polling_verb' => 'post',
|
|
'polling_body' => '{"query": "query { user { id name } }"}',
|
|
]);
|
|
|
|
$plugin->updateDataPayload();
|
|
|
|
Http::assertSent(fn ($request): bool => $request->url() === 'https://example.com/api' &&
|
|
$request->method() === 'POST' &&
|
|
$request->body() === '{"query": "query { user { id name } }"}');
|
|
});
|
|
|
|
test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'polling',
|
|
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news",
|
|
'polling_verb' => 'get',
|
|
'configuration' => [
|
|
'api_key' => 'test123',
|
|
],
|
|
]);
|
|
|
|
// Mock HTTP responses
|
|
Http::fake([
|
|
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
|
|
'https://api2.example.com/weather' => Http::response(['temp' => 25], 200),
|
|
'https://api3.example.com/news' => Http::response(['headline' => 'test'], 200),
|
|
]);
|
|
|
|
$plugin->updateDataPayload();
|
|
|
|
expect($plugin->data_payload)->toHaveKey('IDX_0');
|
|
expect($plugin->data_payload)->toHaveKey('IDX_1');
|
|
expect($plugin->data_payload)->toHaveKey('IDX_2');
|
|
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
|
|
expect($plugin->data_payload['IDX_1'])->toBe(['temp' => 25]);
|
|
expect($plugin->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',
|
|
'polling_url' => 'https://api.example.com/data',
|
|
'polling_verb' => 'get',
|
|
'configuration' => [
|
|
'api_key' => 'test123',
|
|
],
|
|
]);
|
|
|
|
// Mock HTTP response
|
|
Http::fake([
|
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
|
]);
|
|
|
|
$plugin->updateDataPayload();
|
|
|
|
expect($plugin->data_payload)->toBe(['data' => 'test']);
|
|
expect($plugin->data_payload)->not->toHaveKey('IDX_0');
|
|
});
|
|
|
|
test('updateDataPayload resolves Liquid variables in polling_header', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'polling',
|
|
'polling_url' => 'https://api.example.com/data',
|
|
'polling_verb' => 'get',
|
|
'polling_header' => "Authorization: Bearer {{ api_key }}\nX-Custom-Header: {{ custom_value }}",
|
|
'configuration' => [
|
|
'api_key' => 'test123',
|
|
'custom_value' => 'custom_header_value',
|
|
],
|
|
]);
|
|
|
|
// Mock HTTP response
|
|
Http::fake([
|
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
|
]);
|
|
|
|
$plugin->updateDataPayload();
|
|
|
|
Http::assertSent(fn ($request): bool => $request->url() === 'https://api.example.com/data' &&
|
|
$request->method() === 'GET' &&
|
|
$request->header('Authorization')[0] === 'Bearer test123' &&
|
|
$request->header('X-Custom-Header')[0] === 'custom_header_value');
|
|
});
|
|
|
|
test('updateDataPayload resolves Liquid variables in polling_body', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'polling',
|
|
'polling_url' => 'https://api.example.com/data',
|
|
'polling_verb' => 'post',
|
|
'polling_body' => '{"query": "query { user { id name } }", "api_key": "{{ api_key }}", "user_id": "{{ user_id }}"}',
|
|
'configuration' => [
|
|
'api_key' => 'test123',
|
|
'user_id' => '456',
|
|
],
|
|
]);
|
|
|
|
// Mock HTTP response
|
|
Http::fake([
|
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
|
]);
|
|
|
|
$plugin->updateDataPayload();
|
|
|
|
Http::assertSent(function ($request): bool {
|
|
$expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}';
|
|
|
|
return $request->url() === 'https://api.example.com/data' &&
|
|
$request->method() === 'POST' &&
|
|
$request->body() === $expectedBody;
|
|
});
|
|
});
|
|
|
|
test('webhook plugin is stale if webhook event occurred', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'webhook',
|
|
'data_payload_updated_at' => now()->subMinutes(10),
|
|
'data_stale_minutes' => 60, // Should be ignored for webhook
|
|
]);
|
|
|
|
expect($plugin->isDataStale())->toBeTrue();
|
|
|
|
});
|
|
|
|
test('webhook plugin data not stale if no webhook event occurred for 1 hour', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'data_strategy' => 'webhook',
|
|
'data_payload_updated_at' => now()->subMinutes(60),
|
|
'data_stale_minutes' => 60, // Should be ignored for webhook
|
|
]);
|
|
|
|
expect($plugin->isDataStale())->toBeFalse();
|
|
|
|
});
|
|
|
|
test('plugin configuration is cast to array', function (): void {
|
|
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
|
|
$plugin = Plugin::factory()->create(['configuration' => $config]);
|
|
|
|
expect($plugin->configuration)
|
|
->toBeArray()
|
|
->toBe($config);
|
|
});
|
|
|
|
test('plugin can get configuration value by key', function (): void {
|
|
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
|
|
$plugin = Plugin::factory()->create(['configuration' => $config]);
|
|
|
|
expect($plugin->getConfiguration('timezone'))->toBe('UTC');
|
|
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
|
|
expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default');
|
|
});
|
|
|
|
test('plugin configuration template is cast to array', function (): void {
|
|
$template = [
|
|
'custom_fields' => [
|
|
[
|
|
'name' => 'Timezone',
|
|
'keyname' => 'timezone',
|
|
'field_type' => 'time_zone',
|
|
'description' => 'Select your timezone',
|
|
],
|
|
],
|
|
];
|
|
$plugin = Plugin::factory()->create(['configuration_template' => $template]);
|
|
|
|
expect($plugin->configuration_template)
|
|
->toBeArray()
|
|
->toBe($template);
|
|
});
|
|
|
|
test('resolveLiquidVariables resolves variables from configuration', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration' => [
|
|
'api_key' => '12345',
|
|
'username' => 'testuser',
|
|
'count' => 42,
|
|
],
|
|
]);
|
|
|
|
// Test simple variable replacement
|
|
$template = 'API Key: {{ api_key }}';
|
|
$result = $plugin->resolveLiquidVariables($template);
|
|
expect($result)->toBe('API Key: 12345');
|
|
|
|
// Test multiple variables
|
|
$template = 'User: {{ username }}, Count: {{ count }}';
|
|
$result = $plugin->resolveLiquidVariables($template);
|
|
expect($result)->toBe('User: testuser, Count: 42');
|
|
|
|
// Test with missing variable (should keep original)
|
|
$template = 'Missing: {{ missing }}';
|
|
$result = $plugin->resolveLiquidVariables($template);
|
|
expect($result)->toBe('Missing: ');
|
|
|
|
// Test with Liquid control structures
|
|
$template = '{% if count > 40 %}High{% else %}Low{% endif %}';
|
|
$result = $plugin->resolveLiquidVariables($template);
|
|
expect($result)->toBe('High');
|
|
});
|
|
|
|
test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration' => [
|
|
'api_key' => '12345',
|
|
],
|
|
]);
|
|
|
|
// Test with unclosed Liquid tag (should throw exception)
|
|
$template = 'Unclosed tag: {{ config.api_key';
|
|
|
|
expect(fn () => $plugin->resolveLiquidVariables($template))
|
|
->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class);
|
|
});
|
|
|
|
test('plugin can extract default values from custom fields configuration template', function (): void {
|
|
$configurationTemplate = [
|
|
'custom_fields' => [
|
|
[
|
|
'keyname' => 'reading_days',
|
|
'field_type' => 'string',
|
|
'name' => 'Reading Days',
|
|
'description' => 'Select days of the week to read',
|
|
'default' => 'Monday,Friday,Saturday,Sunday',
|
|
],
|
|
[
|
|
'keyname' => 'refresh_interval',
|
|
'field_type' => 'number',
|
|
'name' => 'Refresh Interval',
|
|
'description' => 'How often to refresh data',
|
|
'default' => 30,
|
|
],
|
|
[
|
|
'keyname' => 'timezone',
|
|
'field_type' => 'time_zone',
|
|
'name' => 'Timezone',
|
|
'description' => 'Select your timezone',
|
|
// No default value
|
|
],
|
|
],
|
|
];
|
|
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration_template' => $configurationTemplate,
|
|
'configuration' => [
|
|
'reading_days' => 'Monday,Friday,Saturday,Sunday',
|
|
'refresh_interval' => 30,
|
|
],
|
|
]);
|
|
|
|
expect($plugin->configuration)
|
|
->toBeArray()
|
|
->toHaveKey('reading_days')
|
|
->toHaveKey('refresh_interval')
|
|
->not->toHaveKey('timezone');
|
|
|
|
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
|
|
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
|
|
expect($plugin->getConfiguration('timezone'))->toBeNull();
|
|
});
|
|
|
|
test('resolveLiquidVariables resolves configuration variables correctly', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration' => [
|
|
'Latitude' => '48.2083',
|
|
'Longitude' => '16.3731',
|
|
'api_key' => 'test123',
|
|
],
|
|
]);
|
|
|
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
|
|
$expected = 'https://suntracker.me/?lat=48.2083&lon=16.3731';
|
|
|
|
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
|
|
});
|
|
|
|
test('resolveLiquidVariables handles missing variables gracefully', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration' => [
|
|
'Latitude' => '48.2083',
|
|
],
|
|
]);
|
|
|
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}&key={{ api_key }}';
|
|
$expected = 'https://suntracker.me/?lat=48.2083&lon=&key=';
|
|
|
|
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
|
|
});
|
|
|
|
test('resolveLiquidVariables handles empty configuration', function (): void {
|
|
$plugin = Plugin::factory()->create([
|
|
'configuration' => [],
|
|
]);
|
|
|
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
|
|
$expected = 'https://suntracker.me/?lat=&lon=';
|
|
|
|
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 <script>alert(1)</script>', 'Safe ', '<script>'],
|
|
'attribute_event_handlers' => ['<a onmouseover="alert(1)">Link</a>', '<a>Link</a>', 'onmouseover'],
|
|
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
|
|
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
|
|
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
|
|
]);
|
|
|
|
test('plugin model sanitizes template fields on save', function (string $input, string $expected, string $forbidden): void {
|
|
$user = User::factory()->create();
|
|
|
|
// We test the Model logic directly. This triggers the static::saving hook.
|
|
$plugin = Plugin::create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Security Test',
|
|
'data_stale_minutes' => 15,
|
|
'data_strategy' => 'static',
|
|
'polling_verb' => 'get',
|
|
'configuration_template' => [
|
|
'custom_fields' => [
|
|
[
|
|
'keyname' => 'test_field',
|
|
'description' => $input,
|
|
'help_text' => $input,
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$field = $plugin->fresh()->configuration_template['custom_fields'][0];
|
|
|
|
// Assert the saved data is clean
|
|
expect($field['description'])->toBe($expected)
|
|
->and($field['help_text'])->toBe($expected)
|
|
->and($field['description'])->not->toContain($forbidden);
|
|
})->with('xss_vectors');
|
|
|
|
test('plugin model preserves multi_string csv format', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$plugin = Plugin::create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Multi-string Test',
|
|
'data_stale_minutes' => 15,
|
|
'data_strategy' => 'static',
|
|
'polling_verb' => 'get',
|
|
'configuration' => [
|
|
'tags' => 'laravel,pest,security',
|
|
],
|
|
]);
|
|
|
|
expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security');
|
|
});
|
|
|
|
test('plugin duplicate copies all attributes except id and uuid', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Original Plugin',
|
|
'data_stale_minutes' => 30,
|
|
'data_strategy' => 'polling',
|
|
'polling_url' => 'https://api.example.com/data',
|
|
'polling_verb' => 'get',
|
|
'polling_header' => 'Authorization: Bearer token123',
|
|
'polling_body' => '{"query": "test"}',
|
|
'render_markup' => '<div>Test markup</div>',
|
|
'markup_language' => 'blade',
|
|
'configuration' => ['api_key' => 'secret123'],
|
|
'configuration_template' => [
|
|
'custom_fields' => [
|
|
[
|
|
'keyname' => 'api_key',
|
|
'field_type' => 'string',
|
|
],
|
|
],
|
|
],
|
|
'no_bleed' => true,
|
|
'dark_mode' => true,
|
|
'data_payload' => ['test' => 'data'],
|
|
]);
|
|
|
|
$duplicate = $original->duplicate();
|
|
|
|
// Refresh to ensure casts are applied
|
|
$original->refresh();
|
|
$duplicate->refresh();
|
|
|
|
expect($duplicate->id)->not->toBe($original->id)
|
|
->and($duplicate->uuid)->not->toBe($original->uuid)
|
|
->and($duplicate->name)->toBe('Original Plugin (Copy)')
|
|
->and($duplicate->user_id)->toBe($original->user_id)
|
|
->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes)
|
|
->and($duplicate->data_strategy)->toBe($original->data_strategy)
|
|
->and($duplicate->polling_url)->toBe($original->polling_url)
|
|
->and($duplicate->polling_verb)->toBe($original->polling_verb)
|
|
->and($duplicate->polling_header)->toBe($original->polling_header)
|
|
->and($duplicate->polling_body)->toBe($original->polling_body)
|
|
->and($duplicate->render_markup)->toBe($original->render_markup)
|
|
->and($duplicate->markup_language)->toBe($original->markup_language)
|
|
->and($duplicate->configuration)->toBe($original->configuration)
|
|
->and($duplicate->configuration_template)->toBe($original->configuration_template)
|
|
->and($duplicate->no_bleed)->toBe($original->no_bleed)
|
|
->and($duplicate->dark_mode)->toBe($original->dark_mode)
|
|
->and($duplicate->data_payload)->toBe($original->data_payload)
|
|
->and($duplicate->render_markup_view)->toBeNull();
|
|
});
|
|
|
|
test('plugin duplicate copies render_markup_view file content to render_markup', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
// Create a test blade file
|
|
$testViewPath = resource_path('views/recipes/test-duplicate.blade.php');
|
|
$testContent = '<div class="test-view">Test Content</div>';
|
|
|
|
// Ensure directory exists
|
|
if (! is_dir(dirname($testViewPath))) {
|
|
mkdir(dirname($testViewPath), 0755, true);
|
|
}
|
|
|
|
file_put_contents($testViewPath, $testContent);
|
|
|
|
try {
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user->id,
|
|
'name' => 'View Plugin',
|
|
'render_markup' => null,
|
|
'render_markup_view' => 'recipes.test-duplicate',
|
|
'markup_language' => null,
|
|
]);
|
|
|
|
$duplicate = $original->duplicate();
|
|
|
|
expect($duplicate->render_markup)->toBe($testContent)
|
|
->and($duplicate->markup_language)->toBe('blade')
|
|
->and($duplicate->render_markup_view)->toBeNull()
|
|
->and($duplicate->name)->toBe('View Plugin (Copy)');
|
|
} finally {
|
|
// Clean up test file
|
|
if (file_exists($testViewPath)) {
|
|
unlink($testViewPath);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('plugin duplicate handles liquid file extension', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
// Create a test liquid file
|
|
$testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid');
|
|
$testContent = '<div class="test-view">{{ data.message }}</div>';
|
|
|
|
// Ensure directory exists
|
|
if (! is_dir(dirname($testViewPath))) {
|
|
mkdir(dirname($testViewPath), 0755, true);
|
|
}
|
|
|
|
file_put_contents($testViewPath, $testContent);
|
|
|
|
try {
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Liquid Plugin',
|
|
'render_markup' => null,
|
|
'render_markup_view' => 'recipes.test-duplicate-liquid',
|
|
'markup_language' => null,
|
|
]);
|
|
|
|
$duplicate = $original->duplicate();
|
|
|
|
expect($duplicate->render_markup)->toBe($testContent)
|
|
->and($duplicate->markup_language)->toBe('liquid')
|
|
->and($duplicate->render_markup_view)->toBeNull();
|
|
} finally {
|
|
// Clean up test file
|
|
if (file_exists($testViewPath)) {
|
|
unlink($testViewPath);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('plugin duplicate handles missing view file gracefully', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Missing View Plugin',
|
|
'render_markup' => null,
|
|
'render_markup_view' => 'recipes.nonexistent-view',
|
|
'markup_language' => null,
|
|
]);
|
|
|
|
$duplicate = $original->duplicate();
|
|
|
|
expect($duplicate->render_markup_view)->toBeNull()
|
|
->and($duplicate->name)->toBe('Missing View Plugin (Copy)');
|
|
});
|
|
|
|
test('plugin duplicate uses provided user_id', function (): void {
|
|
$user1 = User::factory()->create();
|
|
$user2 = User::factory()->create();
|
|
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user1->id,
|
|
'name' => 'Original Plugin',
|
|
]);
|
|
|
|
$duplicate = $original->duplicate($user2->id);
|
|
|
|
expect($duplicate->user_id)->toBe($user2->id)
|
|
->and($duplicate->user_id)->not->toBe($original->user_id);
|
|
});
|
|
|
|
test('plugin duplicate falls back to original user_id when no user_id provided', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$original = Plugin::factory()->create([
|
|
'user_id' => $user->id,
|
|
'name' => 'Original Plugin',
|
|
]);
|
|
|
|
$duplicate = $original->duplicate();
|
|
|
|
expect($duplicate->user_id)->toBe($original->user_id);
|
|
});
|