feat: recipes zip import support, add trmnlp compatible recipe configuration
Some checks are pending
tests / ci (push) Waiting to run

* recipes zip import support
* add trmnlp compatible recipe configuration
* support for multiple polling urls
This commit is contained in:
Benjamin Nussbaum 2025-06-13 12:23:52 +02:00
parent a927c0fb97
commit 414ca47cbf
17 changed files with 2409 additions and 125 deletions

View file

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Liquid;
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
use App\Liquid\Tags\TemplateTag;
use Keepsuit\Liquid\Environment;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Tags\RenderTag;
use PHPUnit\Framework\TestCase;
class InlineTemplatesTest extends TestCase
{
protected Environment $environment;
protected InlineTemplatesFileSystem $fileSystem;
protected function setUp(): void
{
parent::setUp();
$this->fileSystem = new InlineTemplatesFileSystem();
$this->environment = new Environment(
fileSystem: $this->fileSystem
);
$this->environment->tagRegistry->register(TemplateTag::class);
$this->environment->tagRegistry->register(RenderTag::class);
}
public function test_template_tag_registers_template(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'size_mod' => '--large',
]
);
$result = $template->render($context);
// Template tag should not output anything
$this->assertEquals('', $result);
// Template should be registered in the file system
$this->assertTrue($this->fileSystem->hasTemplate('session'));
$registeredTemplate = $this->fileSystem->readTemplateFile('session');
$this->assertStringContainsString('{{ facts[randomNumber] }}', $registeredTemplate);
$this->assertStringContainsString('{{ size_mod }}', $registeredTemplate);
}
public function test_template_tag_with_render_tag(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
]
);
$result = $template->render($context);
// Should render the template content
$this->assertStringContainsString('Fact 2', $result); // facts[1]
$this->assertStringContainsString('class="layout"', $result);
$this->assertStringContainsString('class="value text--center"', $result);
}
public function test_apply_liquid_replacements_converts_with_syntax(): void
{
// This test simulates the applyLiquidReplacements method from the Plugin model
$originalLiquid = <<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID;
// Apply the same replacement logic as in Plugin::applyLiquidReplacements
$convertedLiquid = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
'{% render $1, ',
$originalLiquid
);
// Verify the conversion worked
$this->assertStringContainsString('{% render "session",', $convertedLiquid);
$this->assertStringNotContainsString('{% render "session" with', $convertedLiquid);
// Verify the rest of the content is preserved
$this->assertStringContainsString('trmnl: trmnl,', $convertedLiquid);
$this->assertStringContainsString('facts: facts,', $convertedLiquid);
}
public function test_template_tag_with_render_with_tag(): void
{
$originalLiquid = <<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID;
// Apply the same replacement logic as in applyLiquidReplacements
$convertedLiquid = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
'{% render $1, ',
$originalLiquid
);
$template = $this->environment->parseString($convertedLiquid);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
]
);
$result = $template->render($context);
// Should render the template content
$this->assertStringContainsString('Fact 2', $result); // facts[1]
$this->assertStringContainsString('class="layout"', $result);
$this->assertStringContainsString('class="value text--center"', $result);
}
public function test_template_tag_with_multiple_templates(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar",
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'size' => 'full',
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test Plugin']],
]
);
$result = $template->render($context);
// Should render both templates
$this->assertStringContainsString('Fact 2', $result);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_template_tag_invalid_name(): void
{
$this->expectException(LiquidException::class);
$template = $this->environment->parseString(<<<'LIQUID'
{% template invalid-name %}
<div>Content</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext();
$template->render($context);
}
public function test_template_tag_without_file_system(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div>Content</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext();
$result = $template->render($context);
// Should not throw an error and should return empty string
$this->assertEquals('', $result);
}
}

View file

@ -72,6 +72,109 @@ test('updateDataPayload sends POST request with body when polling_verb is post',
});
});
test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () {
$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 handles single URL without nesting', function () {
$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 () {
$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(function ($request) {
return $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 () {
$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) {
$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 () {
$plugin = Plugin::factory()->create([
'data_strategy' => 'webhook',
@ -93,3 +196,168 @@ test('webhook plugin data not stale if no webhook event occurred for 1 hour', fu
expect($plugin->isDataStale())->toBeFalse();
});
test('plugin configuration is cast to array', function () {
$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 () {
$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 () {
$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 () {
$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 () {
$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 () {
$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 () {
$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 () {
$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 () {
$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);
});