From cea493a58055781dd03e35cbc22757861b3c76bb Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 01:09:11 +0300 Subject: [PATCH 01/15] Require laravel/mcp and test the AI assistant MCP server laravel/mcp moves from a dev-only dependency to require, so production installs can serve the AI assistant plugin's MCP endpoint. Adds tests for the MCP endpoint auth (site JWT in X-Authorization), the list-sections tool, SiteContentReader and the chat prompt's sections text. Co-Authored-By: Claude Opus 5.5 --- _api_app/composer.json | 1 + _api_app/composer.lock | 151 +++++++++--------- _api_app/tests/Feature/AiAssistantMcpTest.php | 62 +++++++ .../tests/Feature/AiChatControllerTest.php | 35 ++++ .../tests/Feature/SiteContentReaderTest.php | 47 ++++++ 5 files changed, 221 insertions(+), 75 deletions(-) create mode 100644 _api_app/tests/Feature/AiAssistantMcpTest.php create mode 100644 _api_app/tests/Feature/SiteContentReaderTest.php diff --git a/_api_app/composer.json b/_api_app/composer.json index 5f5168a5e..bd62949d3 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -23,6 +23,7 @@ "intervention/image": "^3.11", "laravel/ai": "^1.0", "laravel/framework": "^13.30", + "laravel/mcp": "^1.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", "mobiledetect/mobiledetectlib": "^4.8", diff --git a/_api_app/composer.lock b/_api_app/composer.lock index c4b495e5b..13b3e855d 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "27814c6f2a0f2dd4cfce7cd20e95ec08", + "content-hash": "863e8d78c2d8d8dd0e23b9fe373d1dd3", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -1880,6 +1880,81 @@ }, "time": "2026-09-29T12:46:21+00:00" }, + { + "name": "laravel/mcp", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9", + "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "jasonmccreary/double": "^1.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-10-21T11:50:42+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.24", @@ -8161,80 +8236,6 @@ }, "time": "2026-09-23T09:33:31+00:00" }, - { - "name": "laravel/mcp", - "version": "v1.0.1", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "92987a9d03847801299ff4abe909ba7686147dde" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde", - "reference": "92987a9d03847801299ff4abe909ba7686147dde", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2", - "symfony/process": "^7.4.5|^8.0.5" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-09-24T10:45:09+00:00" - }, { "name": "laravel/pint", "version": "v1.32.1", diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php new file mode 100644 index 000000000..492ca11c3 --- /dev/null +++ b/_api_app/tests/Feature/AiAssistantMcpTest.php @@ -0,0 +1,62 @@ + '2.0', 'id' => 1, 'method' => 'ping']) + ->assertStatus(401); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('accepts mcp requests with a site jwt in the X-Authorization header', function () { + $token = JWT::encode(['iat' => time(), 'exp' => time() + 300, 'uid' => 1], config('app.key'), 'HS256'); + + postJson('/v1/plugin/ai/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'], ['X-Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJsonPath('id', 1); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('list-sections tool', function () { + beforeEach(function () { + $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + }); + + afterEach(function () { + File::deleteDirectory($this->bertaRoot); + }); + + // Normalization cases are covered in SiteContentReaderTest. + it('returns the main site sections as structured content', function () { + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '' + . '
<![CDATA[Shop]]>
' + . '
', + ); + + BertaSiteServer::actingAs(new User) + ->tool(ListSectionsTool::class) + ->assertOk() + // The hosting gateway calls the tool by this name. + ->assertName('list-sections') + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('sections', [[ + 'name' => 'shop', 'title' => 'Shop', 'type' => 'shop', 'published' => true, 'entry_count' => 2, 'order' => 0, + 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], + ]])); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php index 28c6ca78a..68847bb01 100644 --- a/_api_app/tests/Feature/AiChatControllerTest.php +++ b/_api_app/tests/Feature/AiChatControllerTest.php @@ -3,6 +3,8 @@ use App\Plugins\AiAssistant\AiChatController; use App\Plugins\AiAssistant\AiChatRequest; use App\Plugins\AiAssistant\AssistantAgent; +use App\Plugins\AiAssistant\SiteContentReader; +use Illuminate\Support\Facades\File; use function Pest\Laravel\post; @@ -335,3 +337,36 @@ ->toContain('file_index') ->toContain('manual'); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('describes current sections in the system prompt', function () { + $bertaRoot = sys_get_temp_dir() . '/berta_ai_sections_' . uniqid(); + File::ensureDirectoryExists($bertaRoot . '/storage'); + config(['app.old_berta_root' => $bertaRoot]); + file_put_contents( + $bertaRoot . '/storage/sections.xml', + '' + . '
<![CDATA[Works]]>
' + . '
<![CDATA[Shop]]>' + . '
' + . '
<![CDATA[About]]>
' + . '
', + ); + + try { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildSectionsSection'); + + $result = $method->invoke($controller, (new SiteContentReader)->sections()); + } finally { + File::deleteDirectory($bertaRoot); + } + + expect($result)->toContain( + "### Current Sections\n" + . " order 0: \"Works\" (name: works, type: default, published: 1)\n" + . " order 1: \"Shop\" (name: shop, type: shop, published: 0)\n" + . " SEO — title: \"Buy prints\", keywords: \"prints, art\", description: \"Prints shop\"\n" + . " order 2: \"About\" (name: about, type: default, published: 1)\n" + . "\n### Operations", + ); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php new file mode 100644 index 000000000..e51381b27 --- /dev/null +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -0,0 +1,47 @@ +bertaRoot = sys_get_temp_dir() . '/berta_reader_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); +}); + +afterEach(function () { + File::deleteDirectory($this->bertaRoot); +}); + +it('normalizes sections from storage', function () { + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '' + . '
<![CDATA[Works]]>
' + . '
' + . '
' + . '
<![CDATA[About]]>
' + . '
', + ); + + expect((new SiteContentReader)->sections())->toBe([ + [ + 'name' => 'works', 'title' => 'Works', 'type' => 'default', 'published' => true, 'entry_count' => 3, 'order' => 0, + 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], + ], + [ + 'name' => 'shop', 'title' => '', 'type' => 'shop', 'published' => false, 'entry_count' => 2, 'order' => 1, + 'seo' => ['title' => 'Buy prints', 'keywords' => '', 'description' => ''], + ], + [ + 'name' => 'about', 'title' => 'About', 'type' => 'default', 'published' => true, 'entry_count' => 0, 'order' => 2, + 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], + ], + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns no sections when the site has no sections file', function () { + expect((new SiteContentReader)->sections())->toBe([]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); From 8208d503da80154525e672fbba832ca124a64ac4 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 12:22:59 +0300 Subject: [PATCH 02/15] Share the entry title rule and test the list-entries MCP tool SectionEntryRenderService::showsEntryTitles() holds the rule "entry titles are shown on the default template and in portfolio sections", used by the renderer and the AI assistant plugin. Tests cover the chat's entries tool output, SiteContentReader entries (title rule, unknown and ../ section names) and the list-entries tool. New linkBertaEngine() and writeTemplate() test helpers; the shop test root now uses linkBertaEngine() too. Co-Authored-By: Claude Opus 5.5 --- .../Entries/SectionEntryRenderService.php | 10 ++- _api_app/tests/Feature/AiAssistantMcpTest.php | 57 +++++++++++++ .../Feature/ListSectionEntriesToolTest.php | 55 +++++++++++++ .../tests/Feature/SiteContentReaderTest.php | 79 +++++++++++++++++++ _api_app/tests/Pest.php | 33 ++++++-- 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 _api_app/tests/Feature/ListSectionEntriesToolTest.php diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php index d1464c5bb..1a2c4c67f 100644 --- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php +++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php @@ -35,6 +35,14 @@ public function __construct() $this->galleryGridRenderService = new GalleryGridRenderService; } + /** + * Whether entry titles are shown: on the "default" template and in portfolio sections. + */ + public static function showsEntryTitles(string $templateName, ?string $sectionType): bool + { + return $sectionType == 'portfolio' || $templateName == 'default'; + } + /** * Prepare data for view */ @@ -57,7 +65,7 @@ public function getViewData( $isResponsiveTemplate = isset($siteTemplateSettings['pageLayout']['responsive']) && $siteTemplateSettings['pageLayout']['responsive'] == 'yes'; $isResponsive = $sectionType == 'portfolio' || $isResponsiveTemplate; - if (($sectionType == 'portfolio' || $templateName == 'default') && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) { + if (self::showsEntryTitles($templateName, $sectionType) && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) { $entryTitle = view('Sites/Sections/Entries/_entryTitle', array_merge($entry, [ 'attributes' => [ 'title' => Helpers::arrayToHtmlAttributes([ diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php index 492ca11c3..f66f956c0 100644 --- a/_api_app/tests/Feature/AiAssistantMcpTest.php +++ b/_api_app/tests/Feature/AiAssistantMcpTest.php @@ -3,6 +3,7 @@ use App\Http\Middleware\SetupMiddleware; use App\Models\User; use App\Plugins\AiAssistant\Mcp\BertaSiteServer; +use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool; use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool; use Firebase\JWT\JWT; use Illuminate\Support\Facades\File; @@ -60,3 +61,59 @@ ]])); }); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('list-entries tool', function () { + beforeEach(function () { + $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + // The default template shows entry titles; the title rule is covered in SiteContentReaderTest. + linkBertaEngine($this->bertaRoot); + writeTemplate($this->bertaRoot, 'default'); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + }); + + afterEach(function () { + File::deleteDirectory($this->bertaRoot); + }); + + // Normalization cases are covered in SiteContentReaderTest. + it('returns the entries of a section as structured content', function () { + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '<![CDATA[First]]>Hello

]]>
', + ); + + BertaSiteServer::actingAs(new User) + ->tool(ListEntriesTool::class, ['section' => 'works']) + ->assertOk() + // The hosting gateway calls the tool by this name. + ->assertName('list-entries') + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('section', 'works') + ->where('entry_titles', true) + ->where('entries', [[ + 'id' => '1', 'title' => 'First', 'description' => '

Hello

', 'order' => 0, + 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], + ]])); + }); + + it('rejects sections that do not exist', function () { + BertaSiteServer::actingAs(new User) + ->tool(ListEntriesTool::class, ['section' => '../sections']) + ->assertHasErrors(['Section "../sections" not found. Call list-sections to see the sections.']); + }); + + it('requires the section argument', function () { + BertaSiteServer::actingAs(new User) + ->tool(ListEntriesTool::class, []) + ->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/ListSectionEntriesToolTest.php b/_api_app/tests/Feature/ListSectionEntriesToolTest.php new file mode 100644 index 000000000..dbe9409a7 --- /dev/null +++ b/_api_app/tests/Feature/ListSectionEntriesToolTest.php @@ -0,0 +1,55 @@ +bertaRoot = sys_get_temp_dir() . '/berta_chat_entries_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + // The default template shows entry titles. + linkBertaEngine($this->bertaRoot); + writeTemplate($this->bertaRoot, 'default'); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); +}); + +afterEach(function () { + File::deleteDirectory($this->bertaRoot); +}); + +it('lists section entries for the ai chat', function () { + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '' + . '' + . '<![CDATA[First]]>Hello

]]>
' + . '' + . '
', + ); + + $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works'])); + + expect(json_decode($result, true))->toBe([ + [ + 'id' => '1', 'title' => 'First', 'description' => '

Hello

', 'order' => 0, + 'gallery' => ['type' => 'slideshow', 'file_count' => 3, 'image_count' => 2, 'video_count' => 1], + ], + [ + 'id' => '2', 'title' => '', 'description' => '', 'order' => 1, + 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], + ], + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('tells the ai chat when a section has no entries', function () { + $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works'])); + + expect($result)->toBe('No entries found in this section.'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php index e51381b27..7c6e952a1 100644 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -45,3 +45,82 @@ it('returns no sections when the site has no sections file', function () { expect((new SiteContentReader)->sections())->toBe([]); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('entries', function () { + beforeEach(function () { + // Site settings (for the template) read the engine's setting definitions. + linkBertaEngine($this->bertaRoot); + writeTemplate($this->bertaRoot, 'default'); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + }); + + it('normalizes entries of a section', function () { + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '' + . '' + . '<![CDATA[Series]]>Text

]]>
' + . '' + . '
', + ); + + expect((new SiteContentReader)->entries('works'))->toBe([ + [ + 'id' => '7', 'title' => 'Series', 'description' => '

Text

', 'order' => 0, + 'gallery' => ['type' => 'row', 'file_count' => 2, 'image_count' => 1, 'video_count' => 1], + ], + [ + 'id' => '8', 'title' => '', 'description' => '', 'order' => 1, + 'gallery' => ['type' => 'slideshow', 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], + ], + ]); + }); + + it('leaves out entry titles where the template does not show them', function () { + writeTemplate($this->bertaRoot, 'messy-0.4.2'); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '<![CDATA[Hidden]]>', + ); + + $reader = new SiteContentReader; + + expect($reader->showsEntryTitles('works'))->toBeFalse() + ->and($reader->entries('works')[0])->not->toHaveKey('title'); + }); + + it('keeps entry titles in portfolio sections on any template', function () { + writeTemplate($this->bertaRoot, 'messy-0.4.2'); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '<![CDATA[Project]]>', + ); + + $reader = new SiteContentReader; + + expect($reader->showsEntryTitles('works'))->toBeTrue() + ->and($reader->entries('works')[0]['title'])->toBe('Project'); + }); + + it('knows which sections exist', function () { + $reader = new SiteContentReader; + + expect($reader->hasSection('works'))->toBeTrue() + ->and($reader->hasSection('missing'))->toBeFalse() + ->and($reader->showsEntryTitles('missing'))->toBeFalse(); + }); + + it('reads no entries for sections that do not exist', function (string $section) { + file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1'); + + expect((new SiteContentReader)->entries($section))->toBe([]); + })->with(['missing', '../storage/blog.missing', '']); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index 3d5aed648..70059498c 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -71,6 +71,32 @@ function skipWithoutShopPlugin(): void } } +/** + * Links the legacy engine into a temporary Berta root. Site settings read the engine's + * definitions, and an anonymous request's auth check logs out through the legacy engine. + */ +function linkBertaEngine(string $bertaRoot): void +{ + // The real Berta root, as in config/app.php; tests may already point app.old_berta_root elsewhere + $realRoot = dirname(base_path()); + File::ensureDirectoryExists($bertaRoot . '/engine'); + + foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.php', 'engine/inc.version.php', '_templates'] as $path) { + symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}"); + } +} + +/** + * Sets the main site's template in a temporary Berta root. Needs linkBertaEngine() to be read. + */ +function writeTemplate(string $bertaRoot, string $template): void +{ + file_put_contents( + $bertaRoot . '/storage/settings.xml', + "", + ); +} + /** * A temporary Berta root for the shop plugin's tests, with its shop database and file cache * @@ -81,13 +107,8 @@ function setUpShopTestRoot(): string $realRoot = config('app.old_berta_root'); $bertaRoot = sys_get_temp_dir() . '/berta_shop_' . uniqid(); File::ensureDirectoryExists($bertaRoot . '/storage'); - File::ensureDirectoryExists($bertaRoot . '/engine'); File::ensureDirectoryExists($bertaRoot . '/_plugin_shop'); - // Site settings read the engine's definitions, and an anonymous request's auth check logs - // out through the legacy engine - foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.php', 'engine/inc.version.php', '_templates'] as $path) { - symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}"); - } + linkBertaEngine($bertaRoot); File::copy($realRoot . '/_plugin_shop/inc.setting-definition.php', $bertaRoot . '/_plugin_shop/inc.setting-definition.php'); File::put($bertaRoot . '/engine/hosting', json_encode(['emailFromAddress' => 'shop@berta.test'])); From 249b1bd78977d0ab3c969b6471fe3a9f01eb62c0 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 15:38:58 +0300 Subject: [PATCH 03/15] Test the get-entry-gallery MCP tool and shared gallery reading Pins the chat's GetEntryGalleryTool output, covers SiteContentReader::entryGallery() (untyped files, entries without a gallery, unknown or ../ sections and entries) and the get-entry-gallery tool. Co-Authored-By: Claude Opus 5.5 --- _api_app/tests/Feature/AiAssistantMcpTest.php | 61 +++++++++++++++++++ .../tests/Feature/GetEntryGalleryToolTest.php | 50 +++++++++++++++ .../tests/Feature/SiteContentReaderTest.php | 38 ++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 _api_app/tests/Feature/GetEntryGalleryToolTest.php diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php index f66f956c0..4e3565997 100644 --- a/_api_app/tests/Feature/AiAssistantMcpTest.php +++ b/_api_app/tests/Feature/AiAssistantMcpTest.php @@ -3,6 +3,7 @@ use App\Http\Middleware\SetupMiddleware; use App\Models\User; use App\Plugins\AiAssistant\Mcp\BertaSiteServer; +use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool; use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool; use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool; use Firebase\JWT\JWT; @@ -117,3 +118,63 @@ ->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']); }); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('get-entry-gallery tool', function () { + beforeEach(function () { + $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '', + ); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + }); + + afterEach(function () { + File::deleteDirectory($this->bertaRoot); + }); + + // Gallery normalization is covered in SiteContentReaderTest and GetEntryGalleryToolTest. + it('returns the gallery of an entry as structured content', function () { + BertaSiteServer::actingAs(new User) + ->tool(GetEntryGalleryTool::class, ['section' => 'works', 'entry_id' => '5']) + ->assertOk() + // The hosting gateway offers the tool under this name. + ->assertName('get-entry-gallery') + ->assertStructuredContent([ + 'section' => 'works', + 'entry_id' => '5', + 'settings' => ['type' => 'row'], + 'file_count' => 1, + 'image_count' => 1, + 'video_count' => 0, + 'files' => [['type' => 'image', 'extension' => 'jpg', 'src' => 'a.jpg', 'caption' => 'Caption']], + ]); + }); + + it('rejects unknown sections and entries', function (array $arguments, string $error) { + BertaSiteServer::actingAs(new User) + ->tool(GetEntryGalleryTool::class, $arguments) + ->assertHasErrors([$error]); + })->with([ + 'unknown section' => [['section' => '../sections', 'entry_id' => '5'], 'Section "../sections" not found. Call list-sections to see the sections.'], + 'unknown entry' => [['section' => 'works', 'entry_id' => '99'], 'Entry "99" not found in section "works". Call list-entries to see the entries.'], + ]); + + it('requires the section and entry', function () { + BertaSiteServer::actingAs(new User) + ->tool(GetEntryGalleryTool::class, []) + ->assertHasErrors([ + 'Provide the section name. Call list-sections to see the sections.', + 'Provide the entry ID. Call list-entries to see the entries of the section.', + ]); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/GetEntryGalleryToolTest.php b/_api_app/tests/Feature/GetEntryGalleryToolTest.php new file mode 100644 index 000000000..5b4bc0518 --- /dev/null +++ b/_api_app/tests/Feature/GetEntryGalleryToolTest.php @@ -0,0 +1,50 @@ +bertaRoot = sys_get_temp_dir() . '/berta_chat_gallery_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '' + . 'Sunset

]]>
' + . '' + . '
', + ); +}); + +afterEach(function () { + File::deleteDirectory($this->bertaRoot); +}); + +it('describes the gallery of an entry for the ai chat', function () { + $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '5'])); + + expect(json_decode($result, true))->toBe([ + 'settings' => ['type' => 'slideshow', 'autoplay' => '3'], + 'file_count' => 2, + 'image_count' => 1, + 'video_count' => 1, + 'files' => [ + ['type' => 'image', 'extension' => 'jpg', 'src' => 'Photo.JPG', 'caption' => '

Sunset

'], + ['type' => 'video', 'extension' => 'mp4', 'src' => 'clip.mp4', 'caption' => ''], + ], + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('tells the ai chat when the entry does not exist', function () { + $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99'])); + + expect($result)->toBe('Entry not found.'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php index 7c6e952a1..17fbb7efc 100644 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -124,3 +124,41 @@ expect((new SiteContentReader)->entries($section))->toBe([]); })->with(['missing', '../storage/blog.missing', '']); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +// File type, extension and caption normalization is pinned in GetEntryGalleryToolTest. +describe('entry gallery', function () { + beforeEach(function () { + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '' + . '' + . '', + ); + }); + + it('treats files without a type as images', function () { + $gallery = (new SiteContentReader)->entryGallery('works', '1'); + + expect($gallery['files'])->toBe([['type' => 'image', 'extension' => 'png', 'src' => 'untyped.png', 'caption' => '']]) + ->and($gallery['image_count'])->toBe(1); + }); + + it('describes an entry without gallery as empty', function () { + expect((new SiteContentReader)->entryGallery('works', '2'))->toBe([ + 'settings' => [], 'file_count' => 0, 'image_count' => 0, 'video_count' => 0, 'files' => [], + ]); + }); + + it('finds no gallery for unknown sections or entries', function (string $section, string $entryId) { + expect((new SiteContentReader)->entryGallery($section, $entryId))->toBeNull(); + })->with([ + 'unknown entry' => ['works', '99'], + 'unknown section' => ['missing', '1'], + 'path outside storage' => ['../storage/blog.works', '1'], + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); From 86621e66ed3dc01fb0aeb5a39faf0b7d26015d20 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 17:11:01 +0300 Subject: [PATCH 04/15] Test the chat's entry content tool and SiteContentReader::entryContent() Pins the GetEntryContentTool output on the default template, checks that titles are left out on templates without entry titles, and that entryContent() matches the entry list and rejects unknown or ../ sections and entries. Co-Authored-By: Claude Opus 5.5 --- .../tests/Feature/GetEntryContentToolTest.php | 53 +++++++++++++++++++ .../tests/Feature/SiteContentReaderTest.php | 13 +++++ 2 files changed, 66 insertions(+) create mode 100644 _api_app/tests/Feature/GetEntryContentToolTest.php diff --git a/_api_app/tests/Feature/GetEntryContentToolTest.php b/_api_app/tests/Feature/GetEntryContentToolTest.php new file mode 100644 index 000000000..18a58f9ea --- /dev/null +++ b/_api_app/tests/Feature/GetEntryContentToolTest.php @@ -0,0 +1,53 @@ +bertaRoot = sys_get_temp_dir() . '/berta_chat_content_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + // The default template shows entry titles. + linkBertaEngine($this->bertaRoot); + writeTemplate($this->bertaRoot, 'default'); + file_put_contents( + $this->bertaRoot . '/storage/sections.xml', + '
<![CDATA[Works]]>
', + ); + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '' + . '<![CDATA[First]]>' + . '<![CDATA[Second]]>Hello

]]>
' + . '
', + ); +}); + +afterEach(function () { + File::deleteDirectory($this->bertaRoot); +}); + +it('returns the content of an entry for the ai chat', function () { + $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2'])); + + expect(json_decode($result, true))->toBe([ + 'id' => '2', 'title' => 'Second', 'description' => '

Hello

', 'order' => 1, + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('tells the ai chat when the entry does not exist', function () { + $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99'])); + + expect($result)->toBe('Entry not found.'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('leaves out the title where the template has no entry titles', function () { + writeTemplate($this->bertaRoot, 'messy-0.4.2'); + + $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2'])); + + expect(json_decode($result, true))->toBe(['id' => '2', 'description' => '

Hello

', 'order' => 1]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php index 17fbb7efc..6af3eb21b 100644 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -118,6 +118,19 @@ ->and($reader->showsEntryTitles('missing'))->toBeFalse(); }); + it('reads one entry like the entry list', function () { + file_put_contents( + $this->bertaRoot . '/storage/blog.works.xml', + '<![CDATA[First]]>', + ); + $reader = new SiteContentReader; + + expect($reader->entryContent('works', '1'))->toBe($reader->entries('works')[0]) + ->and($reader->entryContent('works', '99'))->toBeNull() + ->and($reader->entryContent('missing', '1'))->toBeNull() + ->and($reader->entryContent('../storage/blog.works', '1'))->toBeNull(); + }); + it('reads no entries for sections that do not exist', function (string $section) { file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1'); From 71c85b45c38006fca3c81fa7e9219d0f895982f4 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 18:47:02 +0300 Subject: [PATCH 05/15] Test the get-site-settings MCP tool and snapshot the chat's settings text Covers SiteContentReader::siteSettings() (titles, unknown keys, list items, internal group left out, siteTexts kept) and the get-site-settings tool. A snapshot pins the AI chat's current site settings prompt text ahead of moving it onto the shared reader. Co-Authored-By: Claude Opus 5.5 --- ...nt_site_settings_in_the_system_prompt.snap | 74 +++++++++++++++++++ _api_app/tests/Feature/AiAssistantMcpTest.php | 35 +++++++++ .../tests/Feature/AiChatControllerTest.php | 32 ++++++++ .../tests/Feature/SiteContentReaderTest.php | 38 ++++++++++ 4 files changed, 179 insertions(+) create mode 100644 _api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap diff --git a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap new file mode 100644 index 000000000..8e800383a --- /dev/null +++ b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap @@ -0,0 +1,74 @@ +berta: + installed: 1 +siteTexts (Texts in the website): + siteHeading (Site heading — Name of your site. Visible in all sections.): +template (Template): + template (Template — Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.): messy-0.4.2 +theme (Theme): + theme (Choose theme — Themes are ready made sites with predefined design settings.): /themes +texts (Texts): + ownerName (Your name — Your name will be put in a meta-tag in the code of your site. You can choose any name ;)): + pageTitle (Page title (title bar) — Text that appears in the bowser title bar): My site + metaDescription ( description — Short site description. It should not be longer than one or two sentences.): Personal portfolio built with Berta + unknownKey: kept +pageLayout (Page layout): + favicon (Favicon — Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.): + gridStep (Grid step — Distance in pixels for snap-to-grid dragging.): 10 + showGrid (Show gridlines — Choose "yes" to show gridlines in background. Available only in Messy template.): no + gridColor (Gridlines color): black +navigation (Navigation): + landingSectionVisible (Is first section visible in menu? — Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.): yes + landingSectionPageHeadingVisible (Show page heading in first section? — Choose "no" to hide page heading in first section.): yes + landingSectionMenuVisible (Show menu in first section? — Choose "no" to hide the menu in first section.): yes + alwaysSelectTag (Always auto-select a submenu item? — Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.): yes + backToTopEnabled (Enable navigation to page top — Shows navigation button at the bottom when page is scrolled.): no +entryLayout (Entry layout): + galleryFullScreenSettings (Gallery fullscreen settings:): + galleryFullScreenDefault (Is enabled by default — Enables gallery fullscreen mode for new entries.): yes + galleryFullScreenBackground (Background color — Color of the fullscreen gallery background.): black + galleryFullScreenImageNumbers (Image numbers — Enables fullscreen gallery slide numbers.): yes + galleryFullScreenCaptionAlign (Caption alignment — Positioning of the image caption text.): left + group_gallery (Image gallery appearance:): + gallerySlideshowAutoRewind (Slideshow loop mode): no + gallerySlideNumberVisibilityDefault (Show slideshow image numbers — Set the default state of image number visibility in slideshow galleries.): yes +media (Media): + imagesSmallWidth (Small image width — Maximum size of a small image (visible if 'Small images' are switched on in the gallery editor). These settings don't affect original image.): 200 + imagesSmallHeight (Small image height): 200 + imagesMediumWidth (Medium image width — Maximum size of a medium image (visible if 'Medium images' are switched on in the gallery editor). These settings don't affect original image.): 400 + imagesMediumHeight (Medium image height): 400 + imagesLargeWidth (Large image width — Maximum size of a large image (visible if 'Large images' are switched on in the gallery editor). These settings don't affect original image.): 600 + imagesLargeHeight (Large image height): 600 +socialMediaLinks (Social media buttons): + location (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer +socialMediaButtons (Custom social media buttons): + socialMediaHTML (HTML code — Paste or write your HTML code here.): + socialMediaJS (Javascript code — Paste or write your Javascript code here.): + socialMediaLocation (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer +banners (Banners): + banner1_image (Banner image (1) — Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.): + banner1_link (Banner link (1) — Banner link 'https://' should be in front of the address): + banner2_image (Banner image (2)): + banner2_link (Banner link (2)): + banner3_image (Banner image (3)): + banner3_link (Banner link (3)): + banner4_image (Banner image (4)): + banner4_link (Banner link (4)): + banner5_image (Banner image (5)): + banner5_link (Banner link (5)): + banner6_image (Banner image (6)): + banner6_link (Banner link (6)): + banner7_image (Banner image (7)): + banner7_link (Banner link (7)): + banner8_image (Banner image (8)): + banner8_link (Banner link (8)): + banner9_image (Banner image (9)): + banner9_link (Banner link (9)): + banner10_image (Banner image (10)): + banner10_link (Banner link (10)): +language (Language): + language (Interface language — Choose language of interface.): en +settings (Other settings): + googleAnalyticsId (Google Analytics measurement ID — How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.): + googleTagManagerContainerId (Google Tag Manager container ID — How to get the container ID. Leave blank if Google Analytics measurement ID is filled.): + googleSiteVerification (Google site verification tag — Google ownership verification tag. More info.): + jsInclude (Javascript include — Javascript code included right before closing </body> element. Learn more how to include JavaScript code.): \ No newline at end of file diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php index 4e3565997..2ef843ba7 100644 --- a/_api_app/tests/Feature/AiAssistantMcpTest.php +++ b/_api_app/tests/Feature/AiAssistantMcpTest.php @@ -4,6 +4,7 @@ use App\Models\User; use App\Plugins\AiAssistant\Mcp\BertaSiteServer; use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool; +use App\Plugins\AiAssistant\Mcp\Tools\GetSiteSettingsTool; use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool; use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool; use Firebase\JWT\JWT; @@ -178,3 +179,37 @@ ]); }); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('get-site-settings tool', function () { + beforeEach(function () { + $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + linkBertaEngine($this->bertaRoot); + file_put_contents( + $this->bertaRoot . '/storage/settings.xml', + '', + ); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + }); + + afterEach(function () { + File::deleteDirectory($this->bertaRoot); + }); + + // Settings normalization is covered in SiteContentReaderTest. + it('returns the site settings as structured content', function () { + BertaSiteServer::actingAs(new User) + ->tool(GetSiteSettingsTool::class) + ->assertOk() + // The hosting gateway offers the tool under this name. + ->assertName('get-site-settings') + ->assertStructuredContent(fn (AssertableJson $json) => $json->where( + 'groups', + fn ($groups) => $groups->pluck('name')->doesntContain('berta') + && collect($groups->firstWhere('name', 'texts')['settings'])->firstWhere('key', 'pageTitle')['value'] === 'My site', + )); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php index 68847bb01..330de5d7d 100644 --- a/_api_app/tests/Feature/AiChatControllerTest.php +++ b/_api_app/tests/Feature/AiChatControllerTest.php @@ -1,9 +1,11 @@ skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('describes current site settings in the system prompt', function () { + $bertaRoot = sys_get_temp_dir() . '/berta_ai_settings_' . uniqid(); + File::ensureDirectoryExists($bertaRoot . '/storage'); + linkBertaEngine($bertaRoot); + config(['app.old_berta_root' => $bertaRoot]); + file_put_contents( + $bertaRoot . '/storage/settings.xml', + '' + . '' + . '' + . '' + . '', + ); + + try { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'formatCurrentSettings'); + + $result = $method->invoke($controller, (new SiteSettingsDataService(''))->getState(), (new SiteSettingsConfigService)->get()); + } finally { + File::deleteDirectory($bertaRoot); + } + + expect($result)->toContain('texts (') + ->and($result)->toContain(' pageTitle (') + ->and($result)->toContain(' unknownKey: kept') + ->and($result)->not->toContain('https://x.com') + ->and($result)->toMatchSnapshot(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php index 6af3eb21b..58865461f 100644 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -175,3 +175,41 @@ 'path outside storage' => ['../storage/blog.works', '1'], ]); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('site settings', function () { + beforeEach(function () { + // Site settings read the engine's setting definitions. + linkBertaEngine($this->bertaRoot); + file_put_contents( + $this->bertaRoot . '/storage/settings.xml', + '' + . '' + . '' + . '' + . '' + . '', + ); + }); + + it('describes settings with their titles and leaves out the internal group', function () { + $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name'); + $texts = collect($groups['texts']['settings'])->keyBy('key'); + + expect($groups->keys())->not->toContain('berta') + // Not on the settings page, but real site content: the site heading and footer texts. + ->and(collect($groups['siteTexts']['settings'])->keyBy('key')['siteHeading']['value'])->toBe('Acme') + ->and($groups['texts']['title'])->toBeString() + ->and($texts['pageTitle']['value'])->toBe('My site') + ->and($texts['pageTitle']['title'])->toBeString() + ->and($texts['pageTitle']['items'])->toBeNull() + ->and($texts['unknownKey'])->toBe(['key' => 'unknownKey', 'title' => null, 'description' => null, 'value' => 'kept', 'items' => null]); + }); + + it('lists the entries of list settings as items', function () { + $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name'); + $links = collect($groups['socialMediaLinks']['settings'])->keyBy('key')['links']; + + expect($links['value'])->toBe('') + ->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); From 2480a2dfcb4fc7cdeda7730a43fa904931204990 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 19:43:16 +0300 Subject: [PATCH 06/15] Test the get-design-settings MCP tool and design settings reading Co-Authored-By: Claude Opus 5.5 --- _api_app/tests/Feature/AiAssistantMcpTest.php | 37 ++++++++++++++++++ .../tests/Feature/SiteContentReaderTest.php | 38 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php index 2ef843ba7..a387d85fd 100644 --- a/_api_app/tests/Feature/AiAssistantMcpTest.php +++ b/_api_app/tests/Feature/AiAssistantMcpTest.php @@ -3,6 +3,7 @@ use App\Http\Middleware\SetupMiddleware; use App\Models\User; use App\Plugins\AiAssistant\Mcp\BertaSiteServer; +use App\Plugins\AiAssistant\Mcp\Tools\GetDesignSettingsTool; use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool; use App\Plugins\AiAssistant\Mcp\Tools\GetSiteSettingsTool; use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool; @@ -213,3 +214,39 @@ )); }); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('get-design-settings tool', function () { + beforeEach(function () { + $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); + linkBertaEngine($this->bertaRoot); + writeTemplate($this->bertaRoot, 'messy-0.4.2'); + file_put_contents( + $this->bertaRoot . '/storage/settings.messy.xml', + '', + ); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + }); + + afterEach(function () { + File::deleteDirectory($this->bertaRoot); + }); + + // Settings normalization is covered in SiteContentReaderTest. + it('returns the design settings of the active template as structured content', function () { + BertaSiteServer::actingAs(new User) + ->tool(GetDesignSettingsTool::class) + ->assertOk() + // The hosting gateway offers the tool under this name. + ->assertName('get-design-settings') + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('template', 'messy-0.4.2') + ->where( + 'groups', + fn ($groups) => collect($groups->firstWhere('name', 'generalFontSettings')['settings'])->firstWhere('key', 'color')['value'] === '#ff0000', + )); + }); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php index 58865461f..b4413889e 100644 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ b/_api_app/tests/Feature/SiteContentReaderTest.php @@ -213,3 +213,41 @@ ->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]); }); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +describe('design settings', function () { + beforeEach(function () { + // Design settings read the template's setting definitions. + linkBertaEngine($this->bertaRoot); + }); + + it('describes the active template settings with their titles', function () { + writeTemplate($this->bertaRoot, 'messy-0.4.2'); + file_put_contents( + $this->bertaRoot . '/storage/settings.messy.xml', + '', + ); + + $design = (new SiteContentReader)->designSettings(); + $groups = collect($design['groups'])->keyBy('name'); + $color = collect($groups['generalFontSettings']['settings'])->keyBy('key')['color']; + + expect($design['template'])->toBe('messy-0.4.2') + ->and($groups['generalFontSettings']['title'])->toBeString() + ->and($color['value'])->toBe('#ff0000') + ->and($color['title'])->toBeString() + // Settings the site never changed come with the template's defaults. + ->and($groups['menu']['settings'])->not->toBeEmpty(); + }); + + it('reads the installed template when the site names an unknown one', function (string $template) { + writeTemplate($this->bertaRoot, $template); + + $design = (new SiteContentReader)->designSettings(); + + expect($design['template'])->toBe('messy-0.4.2') + ->and($design['groups'])->not->toBeEmpty(); + })->with([ + 'older version' => 'messy-0.3.0', + 'not installed' => 'missing-1.0.0', + ]); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); From 8f07a1ae63452d0eb1e4dae5cc72b6f02a22aaf5 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 23 Oct 2026 20:03:17 +0300 Subject: [PATCH 07/15] Run plugin tests from the plugins' own repositories phpunit.xml and Pest pick up app/Plugins/*/tests, so the AI assistant plugin's tests move to its repository. Core gets its own test for the entry title rule, which only the plugin's tests covered. Co-Authored-By: Claude Opus 5.5 --- _api_app/phpunit.xml | 6 + ...nt_site_settings_in_the_system_prompt.snap | 74 ---- _api_app/tests/Feature/AiAssistantMcpTest.php | 252 ----------- .../Feature/AiAssistantQuotaServiceTest.php | 137 ------ .../tests/Feature/AiChatControllerTest.php | 404 ------------------ .../tests/Feature/GetEntryContentToolTest.php | 53 --- .../tests/Feature/GetEntryGalleryToolTest.php | 50 --- .../Feature/ListSectionEntriesToolTest.php | 55 --- .../tests/Feature/SiteContentReaderTest.php | 253 ----------- _api_app/tests/Pest.php | 3 +- .../Unit/SectionEntryRenderServiceTest.php | 13 + 11 files changed, 21 insertions(+), 1279 deletions(-) delete mode 100644 _api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap delete mode 100644 _api_app/tests/Feature/AiAssistantMcpTest.php delete mode 100644 _api_app/tests/Feature/AiAssistantQuotaServiceTest.php delete mode 100644 _api_app/tests/Feature/AiChatControllerTest.php delete mode 100644 _api_app/tests/Feature/GetEntryContentToolTest.php delete mode 100644 _api_app/tests/Feature/GetEntryGalleryToolTest.php delete mode 100644 _api_app/tests/Feature/ListSectionEntriesToolTest.php delete mode 100644 _api_app/tests/Feature/SiteContentReaderTest.php create mode 100644 _api_app/tests/Unit/SectionEntryRenderServiceTest.php diff --git a/_api_app/phpunit.xml b/_api_app/phpunit.xml index 506b9a38e..a7900cf71 100644 --- a/_api_app/phpunit.xml +++ b/_api_app/phpunit.xml @@ -11,11 +11,17 @@ tests/Feature + + app/Plugins/*/tests + app + + app/Plugins/*/tests + diff --git a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap deleted file mode 100644 index 8e800383a..000000000 --- a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap +++ /dev/null @@ -1,74 +0,0 @@ -berta: - installed: 1 -siteTexts (Texts in the website): - siteHeading (Site heading — Name of your site. Visible in all sections.): -template (Template): - template (Template — Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.): messy-0.4.2 -theme (Theme): - theme (Choose theme — Themes are ready made sites with predefined design settings.): /themes -texts (Texts): - ownerName (Your name — Your name will be put in a meta-tag in the code of your site. You can choose any name ;)): - pageTitle (Page title (title bar) — Text that appears in the bowser title bar): My site - metaDescription ( description — Short site description. It should not be longer than one or two sentences.): Personal portfolio built with Berta - unknownKey: kept -pageLayout (Page layout): - favicon (Favicon — Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.): - gridStep (Grid step — Distance in pixels for snap-to-grid dragging.): 10 - showGrid (Show gridlines — Choose "yes" to show gridlines in background. Available only in Messy template.): no - gridColor (Gridlines color): black -navigation (Navigation): - landingSectionVisible (Is first section visible in menu? — Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.): yes - landingSectionPageHeadingVisible (Show page heading in first section? — Choose "no" to hide page heading in first section.): yes - landingSectionMenuVisible (Show menu in first section? — Choose "no" to hide the menu in first section.): yes - alwaysSelectTag (Always auto-select a submenu item? — Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.): yes - backToTopEnabled (Enable navigation to page top — Shows navigation button at the bottom when page is scrolled.): no -entryLayout (Entry layout): - galleryFullScreenSettings (Gallery fullscreen settings:): - galleryFullScreenDefault (Is enabled by default — Enables gallery fullscreen mode for new entries.): yes - galleryFullScreenBackground (Background color — Color of the fullscreen gallery background.): black - galleryFullScreenImageNumbers (Image numbers — Enables fullscreen gallery slide numbers.): yes - galleryFullScreenCaptionAlign (Caption alignment — Positioning of the image caption text.): left - group_gallery (Image gallery appearance:): - gallerySlideshowAutoRewind (Slideshow loop mode): no - gallerySlideNumberVisibilityDefault (Show slideshow image numbers — Set the default state of image number visibility in slideshow galleries.): yes -media (Media): - imagesSmallWidth (Small image width — Maximum size of a small image (visible if 'Small images' are switched on in the gallery editor). These settings don't affect original image.): 200 - imagesSmallHeight (Small image height): 200 - imagesMediumWidth (Medium image width — Maximum size of a medium image (visible if 'Medium images' are switched on in the gallery editor). These settings don't affect original image.): 400 - imagesMediumHeight (Medium image height): 400 - imagesLargeWidth (Large image width — Maximum size of a large image (visible if 'Large images' are switched on in the gallery editor). These settings don't affect original image.): 600 - imagesLargeHeight (Large image height): 600 -socialMediaLinks (Social media buttons): - location (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer -socialMediaButtons (Custom social media buttons): - socialMediaHTML (HTML code — Paste or write your HTML code here.): - socialMediaJS (Javascript code — Paste or write your Javascript code here.): - socialMediaLocation (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer -banners (Banners): - banner1_image (Banner image (1) — Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.): - banner1_link (Banner link (1) — Banner link 'https://' should be in front of the address): - banner2_image (Banner image (2)): - banner2_link (Banner link (2)): - banner3_image (Banner image (3)): - banner3_link (Banner link (3)): - banner4_image (Banner image (4)): - banner4_link (Banner link (4)): - banner5_image (Banner image (5)): - banner5_link (Banner link (5)): - banner6_image (Banner image (6)): - banner6_link (Banner link (6)): - banner7_image (Banner image (7)): - banner7_link (Banner link (7)): - banner8_image (Banner image (8)): - banner8_link (Banner link (8)): - banner9_image (Banner image (9)): - banner9_link (Banner link (9)): - banner10_image (Banner image (10)): - banner10_link (Banner link (10)): -language (Language): - language (Interface language — Choose language of interface.): en -settings (Other settings): - googleAnalyticsId (Google Analytics measurement ID — How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.): - googleTagManagerContainerId (Google Tag Manager container ID — How to get the container ID. Leave blank if Google Analytics measurement ID is filled.): - googleSiteVerification (Google site verification tag — Google ownership verification tag. More info.): - jsInclude (Javascript include — Javascript code included right before closing </body> element. Learn more how to include JavaScript code.): \ No newline at end of file diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php deleted file mode 100644 index a387d85fd..000000000 --- a/_api_app/tests/Feature/AiAssistantMcpTest.php +++ /dev/null @@ -1,252 +0,0 @@ - '2.0', 'id' => 1, 'method' => 'ping']) - ->assertStatus(401); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('accepts mcp requests with a site jwt in the X-Authorization header', function () { - $token = JWT::encode(['iat' => time(), 'exp' => time() + 300, 'uid' => 1], config('app.key'), 'HS256'); - - postJson('/v1/plugin/ai/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'], ['X-Authorization' => "Bearer {$token}"]) - ->assertOk() - ->assertJsonPath('id', 1); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('list-sections tool', function () { - beforeEach(function () { - $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - }); - - afterEach(function () { - File::deleteDirectory($this->bertaRoot); - }); - - // Normalization cases are covered in SiteContentReaderTest. - it('returns the main site sections as structured content', function () { - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '' - . '
<![CDATA[Shop]]>
' - . '
', - ); - - BertaSiteServer::actingAs(new User) - ->tool(ListSectionsTool::class) - ->assertOk() - // The hosting gateway calls the tool by this name. - ->assertName('list-sections') - ->assertStructuredContent(fn (AssertableJson $json) => $json->where('sections', [[ - 'name' => 'shop', 'title' => 'Shop', 'type' => 'shop', 'published' => true, 'entry_count' => 2, 'order' => 0, - 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], - ]])); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('list-entries tool', function () { - beforeEach(function () { - $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - // The default template shows entry titles; the title rule is covered in SiteContentReaderTest. - linkBertaEngine($this->bertaRoot); - writeTemplate($this->bertaRoot, 'default'); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - }); - - afterEach(function () { - File::deleteDirectory($this->bertaRoot); - }); - - // Normalization cases are covered in SiteContentReaderTest. - it('returns the entries of a section as structured content', function () { - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '<![CDATA[First]]>Hello

]]>
', - ); - - BertaSiteServer::actingAs(new User) - ->tool(ListEntriesTool::class, ['section' => 'works']) - ->assertOk() - // The hosting gateway calls the tool by this name. - ->assertName('list-entries') - ->assertStructuredContent(fn (AssertableJson $json) => $json - ->where('section', 'works') - ->where('entry_titles', true) - ->where('entries', [[ - 'id' => '1', 'title' => 'First', 'description' => '

Hello

', 'order' => 0, - 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], - ]])); - }); - - it('rejects sections that do not exist', function () { - BertaSiteServer::actingAs(new User) - ->tool(ListEntriesTool::class, ['section' => '../sections']) - ->assertHasErrors(['Section "../sections" not found. Call list-sections to see the sections.']); - }); - - it('requires the section argument', function () { - BertaSiteServer::actingAs(new User) - ->tool(ListEntriesTool::class, []) - ->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('get-entry-gallery tool', function () { - beforeEach(function () { - $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '', - ); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - }); - - afterEach(function () { - File::deleteDirectory($this->bertaRoot); - }); - - // Gallery normalization is covered in SiteContentReaderTest and GetEntryGalleryToolTest. - it('returns the gallery of an entry as structured content', function () { - BertaSiteServer::actingAs(new User) - ->tool(GetEntryGalleryTool::class, ['section' => 'works', 'entry_id' => '5']) - ->assertOk() - // The hosting gateway offers the tool under this name. - ->assertName('get-entry-gallery') - ->assertStructuredContent([ - 'section' => 'works', - 'entry_id' => '5', - 'settings' => ['type' => 'row'], - 'file_count' => 1, - 'image_count' => 1, - 'video_count' => 0, - 'files' => [['type' => 'image', 'extension' => 'jpg', 'src' => 'a.jpg', 'caption' => 'Caption']], - ]); - }); - - it('rejects unknown sections and entries', function (array $arguments, string $error) { - BertaSiteServer::actingAs(new User) - ->tool(GetEntryGalleryTool::class, $arguments) - ->assertHasErrors([$error]); - })->with([ - 'unknown section' => [['section' => '../sections', 'entry_id' => '5'], 'Section "../sections" not found. Call list-sections to see the sections.'], - 'unknown entry' => [['section' => 'works', 'entry_id' => '99'], 'Entry "99" not found in section "works". Call list-entries to see the entries.'], - ]); - - it('requires the section and entry', function () { - BertaSiteServer::actingAs(new User) - ->tool(GetEntryGalleryTool::class, []) - ->assertHasErrors([ - 'Provide the section name. Call list-sections to see the sections.', - 'Provide the entry ID. Call list-entries to see the entries of the section.', - ]); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('get-site-settings tool', function () { - beforeEach(function () { - $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - linkBertaEngine($this->bertaRoot); - file_put_contents( - $this->bertaRoot . '/storage/settings.xml', - '', - ); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - }); - - afterEach(function () { - File::deleteDirectory($this->bertaRoot); - }); - - // Settings normalization is covered in SiteContentReaderTest. - it('returns the site settings as structured content', function () { - BertaSiteServer::actingAs(new User) - ->tool(GetSiteSettingsTool::class) - ->assertOk() - // The hosting gateway offers the tool under this name. - ->assertName('get-site-settings') - ->assertStructuredContent(fn (AssertableJson $json) => $json->where( - 'groups', - fn ($groups) => $groups->pluck('name')->doesntContain('berta') - && collect($groups->firstWhere('name', 'texts')['settings'])->firstWhere('key', 'pageTitle')['value'] === 'My site', - )); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('get-design-settings tool', function () { - beforeEach(function () { - $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - linkBertaEngine($this->bertaRoot); - writeTemplate($this->bertaRoot, 'messy-0.4.2'); - file_put_contents( - $this->bertaRoot . '/storage/settings.messy.xml', - '', - ); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - }); - - afterEach(function () { - File::deleteDirectory($this->bertaRoot); - }); - - // Settings normalization is covered in SiteContentReaderTest. - it('returns the design settings of the active template as structured content', function () { - BertaSiteServer::actingAs(new User) - ->tool(GetDesignSettingsTool::class) - ->assertOk() - // The hosting gateway offers the tool under this name. - ->assertName('get-design-settings') - ->assertStructuredContent(fn (AssertableJson $json) => $json - ->where('template', 'messy-0.4.2') - ->where( - 'groups', - fn ($groups) => collect($groups->firstWhere('name', 'generalFontSettings')['settings'])->firstWhere('key', 'color')['value'] === '#ff0000', - )); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php b/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php deleted file mode 100644 index 4d09ad70e..000000000 --- a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php +++ /dev/null @@ -1,137 +0,0 @@ -makePartial(); - $user->shouldReceive('getPlan')->andReturn($plan); - $user->plans = $plans ?: [ - ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]], - ['id' => '2', 'name' => 'Pro', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]], - ['id' => '3', 'name' => 'Shop', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]], - ]; - - return $user; -} - -it('returns 30 requests per day for basic plan', function () { - $service = new AiAssistantQuotaService; - - expect($service->getDailyLimit(makeUser(1)))->toBe(30); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns 100 requests per day for pro plan', function () { - $service = new AiAssistantQuotaService; - - expect($service->getDailyLimit(makeUser(2)))->toBe(100); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns 100 requests per day for shop plan', function () { - $service = new AiAssistantQuotaService; - - expect($service->getDailyLimit(makeUser(3)))->toBe(100); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('falls back to trial plan (id=0) limit when plan id is not found', function () { - $service = new AiAssistantQuotaService; - $user = makeUser(9, [ - ['id' => '0', 'name' => 'Trial', 'features' => [], 'limits' => ['ai_assistant_daily' => 5]], - ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]], - ]); - - expect($service->getDailyLimit($user))->toBe(5); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns 0 when plan id is not found and no trial plan exists', function () { - $service = new AiAssistantQuotaService; - $user = makeUser(9, [ - ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]], - ]); - - expect($service->getDailyLimit($user))->toBe(0); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns 0 when plan has no ai_assistant_daily limit configured', function () { - $service = new AiAssistantQuotaService; - $user = makeUser(1, [ - ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => []], - ]); - - expect($service->getDailyLimit($user))->toBe(0); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns 0 when plan id is not found in plans list', function () { - $service = new AiAssistantQuotaService; - $user = makeUser(9, []); - - expect($service->getDailyLimit($user))->toBe(0); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('formats cache key with current date', function () { - $service = new AiAssistantQuotaService; - - expect($service->getCacheKey())->toBe('ai_requests:' . now()->format('Y-m-d')); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns zero count when no requests have been made', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - - expect($service->getCount())->toBe(0); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('increments count on each call', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - - expect($service->increment())->toBe(1) - ->and($service->increment())->toBe(2) - ->and($service->getCount())->toBe(2); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('reports limit not reached when count is below limit', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - $user = makeUser(1); // limit = 30 - - $service->increment(); // count = 1 - - expect($service->isLimitReached($user))->toBeFalse(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('reports limit reached when count equals the daily limit', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - $user = makeUser(1); // limit = 30 - - Cache::put($service->getCacheKey(), 30); - - expect($service->isLimitReached($user))->toBeTrue(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('reports limit reached when count exceeds the daily limit', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - $user = makeUser(1); // limit = 30 - - Cache::put($service->getCacheKey(), 31); - - expect($service->isLimitReached($user))->toBeTrue(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('uses separate keys for different days', function () { - Cache::flush(); - $service = new AiAssistantQuotaService; - - Cache::put('ai_requests:2024-01-01', 99); - Cache::put('ai_requests:2024-01-02', 5); - - // Only the current day's key is used - expect($service->getCount())->toBe(0); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php deleted file mode 100644 index 330de5d7d..000000000 --- a/_api_app/tests/Feature/AiChatControllerTest.php +++ /dev/null @@ -1,404 +0,0 @@ - 'Make the background blue', - 'site' => '', - 'template' => 'default', - ])->assertStatus(401); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('parses structured json response from anthropic', function () { - AssistantAgent::fake([ - '{"reply": "Changed background to blue.", "design_changes": [{"group": "background", "setting": "backgroundColor", "value": "#0000ff"}], "settings_changes": []}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'make background blue']]); - - expect($result['reply'])->toBe('Changed background to blue.') - ->and($result['design_changes'])->toHaveCount(1) - ->and($result['design_changes'][0]['group'])->toBe('background') - ->and($result['design_changes'][0]['setting'])->toBe('backgroundColor') - ->and($result['design_changes'][0]['value'])->toBe('#0000ff') - ->and($result['settings_changes'])->toBeEmpty(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns empty changes when ai response has no json', function () { - AssistantAgent::fake([ - 'I cannot help with that.', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); - - expect($result['reply'])->toBe('I cannot help with that.') - ->and($result['design_changes'])->toBeEmpty() - ->and($result['settings_changes'])->toBeEmpty(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns empty changes when json is embedded in prose', function () { - AssistantAgent::fake([ - 'Sure! Here is my response: {"reply": "Done!", "design_changes": [], "settings_changes": []} — let me know if you need more.', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'reset']]); - - expect($result['reply'])->toBe('Done!') - ->and($result['design_changes'])->toBeEmpty() - ->and($result['settings_changes'])->toBeEmpty(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('parses site settings changes from ai response', function () { - AssistantAgent::fake([ - '{"reply": "Updated the page title.", "design_changes": [], "settings_changes": [{"group": "texts", "setting": "pageTitle", "value": "My Site"}]}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'set the page title to My Site']]); - - expect($result['reply'])->toBe('Updated the page title.') - ->and($result['design_changes'])->toBeEmpty() - ->and($result['settings_changes'])->toHaveCount(1) - ->and($result['settings_changes'][0]['group'])->toBe('texts') - ->and($result['settings_changes'][0]['setting'])->toBe('pageTitle') - ->and($result['settings_changes'][0]['value'])->toBe('My Site'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('parses is_undo from ai response', function () { - AssistantAgent::fake([ - '{"reply": "Reverted font size.", "is_undo": true, "design_changes": [{"group": "bodyText", "setting": "fontSize", "value": "12px"}], "settings_changes": []}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'undo']]); - - expect($result['is_undo'])->toBeTrue() - ->and($result['reply'])->toBe('Reverted font size.') - ->and($result['design_changes'])->toHaveCount(1) - ->and($result['design_changes'][0]['value'])->toBe('12px'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('enriches changes with previous_value', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'enrichChangesWithPreviousValues'); - - $changes = [ - ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px'], - ['group' => 'bodyText', 'setting' => 'fontFamily', 'value' => 'Arial'], - ]; - $currentSettings = [ - 'bodyText' => ['fontSize' => '12px'], - ]; - - $result = $method->invoke($controller, $changes, $currentSettings); - - expect($result[0]['previous_value'])->toBe('12px') - ->and($result[1]['previous_value'])->toBeNull(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes change history in system prompt', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); - - $changeHistory = [ - [ - 'user_message' => 'make the font bigger', - 'design_changes' => [ - ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px', 'previous_value' => '12px'], - ], - 'settings_changes' => [], - ], - [ - 'user_message' => 'make background dark', - 'design_changes' => [ - ['group' => 'background', 'setting' => 'backgroundColor', 'value' => '#000000', 'previous_value' => '#ffffff'], - ], - 'settings_changes' => [], - ], - ]; - - $result = $method->invoke($controller, $changeHistory); - - expect($result) - ->toContain('Change History') - ->toContain('make the font bigger') - ->toContain('bodyText > fontSize') - ->toContain('"12px" → "16px"') - ->toContain('make background dark') - ->toContain('background > backgroundColor') - ->toContain('"#ffffff" → "#000000"'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('omits change history section when history is empty', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); - - $result = $method->invoke($controller, []); - - expect($result)->toBe(''); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('parses entry_changes from ai response', function () { - AssistantAgent::fake([ - '{"reply": "Created an entry.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [{"operation": "create", "section": "blog", "description": "

Hello world

"}]}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'add an entry to blog']]); - - expect($result['entry_changes'])->toHaveCount(1) - ->and($result['entry_changes'][0]['operation'])->toBe('create') - ->and($result['entry_changes'][0]['section'])->toBe('blog') - ->and($result['entry_changes'][0]['description'])->toBe('

Hello world

'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns empty entry_changes when not in ai response', function () { - AssistantAgent::fake([ - '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); - - expect($result['entry_changes'])->toBeArray()->toBeEmpty(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes entry changes in change history section', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); - - $changeHistory = [ - [ - 'user_message' => 'add an entry', - 'design_changes' => [], - 'settings_changes' => [], - 'section_changes' => [], - 'entry_changes' => [ - ['operation' => 'create', 'section' => 'blog', 'entry_id' => '5', 'description' => '

Hello

'], - ], - ], - [ - 'user_message' => 'update entry description', - 'design_changes' => [], - 'settings_changes' => [], - 'section_changes' => [], - 'entry_changes' => [ - ['operation' => 'update', 'section' => 'blog', 'entry_id' => '5', 'value' => '

New

', 'previous_value' => '

Old

'], - ], - ], - ]; - - $result = $method->invoke($controller, $changeHistory); - - expect($result) - ->toContain('add an entry') - ->toContain('entry: create #5 in "blog"') - ->toContain('undo: delete entry 5') - ->toContain('update entry description') - ->toContain('entry: update #5 in "blog"') - ->toContain('Old') - ->toContain('New'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('restricts change_history entry_changes operation to create and update', function () { - $request = new AiChatRequest; - $rules = $request->rules(); - - expect($rules['change_history.*.entry_changes.*.operation']) - ->toContain('in:create,update'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes entries section in system prompt', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildEntriesSection'); - - $result = $method->invoke($controller); - - expect($result) - ->toContain('Entries') - ->toContain('list_section_entries') - ->toContain('get_entry_content') - ->toContain('description') - ->toContain('delete') - ->toContain('manually'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes help articles in system prompt', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildHelpArticlesSection'); - - $result = $method->invoke($controller); - - expect($result) - ->toContain('Help Articles') - ->toContain('How to Add a Video') - ->toContain('https://support.berta.me/en/frequently-asked-questions/how-to-add-a-video') - ->toContain('support.berta.me') - ->toContain('Domains') - ->toContain('SSL Certificates and HTTPS'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('parses gallery_changes from ai response', function () { - AssistantAgent::fake([ - '{"reply": "Updated gallery type.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [], "gallery_changes": [{"operation": "update_setting", "section": "portfolio", "entry_id": "3", "setting": "type", "value": "row"}]}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'change gallery to row']]); - - expect($result['gallery_changes'])->toHaveCount(1) - ->and($result['gallery_changes'][0]['operation'])->toBe('update_setting') - ->and($result['gallery_changes'][0]['section'])->toBe('portfolio') - ->and($result['gallery_changes'][0]['entry_id'])->toBe('3') - ->and($result['gallery_changes'][0]['setting'])->toBe('type') - ->and($result['gallery_changes'][0]['value'])->toBe('row'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns empty gallery_changes when not in ai response', function () { - AssistantAgent::fake([ - '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}', - ]); - - $agent = new AssistantAgent('system prompt'); - $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); - - expect($result['gallery_changes'])->toBeArray()->toBeEmpty(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes gallery changes in change history section', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); - - $changeHistory = [ - [ - 'user_message' => 'change gallery to row', - 'design_changes' => [], - 'settings_changes' => [], - 'section_changes' => [], - 'entry_changes' => [], - 'gallery_changes' => [ - ['operation' => 'update_setting', 'section' => 'portfolio', 'entry_id' => '3', 'setting' => 'type', 'value' => 'row', 'previous_value' => 'slideshow'], - ], - ], - [ - 'user_message' => 'update image caption', - 'design_changes' => [], - 'settings_changes' => [], - 'section_changes' => [], - 'entry_changes' => [], - 'gallery_changes' => [ - ['operation' => 'update_caption', 'section' => 'portfolio', 'entry_id' => '3', 'file_index' => 0, 'value' => '

New caption

', 'previous_value' => '

Old caption

'], - ], - ], - ]; - - $result = $method->invoke($controller, $changeHistory); - - expect($result) - ->toContain('gallery: update_setting entry #3 in "portfolio"') - ->toContain('"slideshow" → "row"') - ->toContain('gallery: update_caption entry #3 in "portfolio" file[0]') - ->toContain('"Old caption" → "New caption"'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('restricts change_history gallery_changes operation to update_setting and update_caption', function () { - $request = new AiChatRequest; - $rules = $request->rules(); - - expect($rules['change_history.*.gallery_changes.*.operation']) - ->toContain('in:update_setting,update_caption'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('includes galleries section in system prompt', function () { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildGalleriesSection'); - - $result = $method->invoke($controller); - - expect($result) - ->toContain('Galleries') - ->toContain('get_entry_gallery') - ->toContain('update_setting') - ->toContain('update_caption') - ->toContain('slideshow') - ->toContain('fullscreen') - ->toContain('file_index') - ->toContain('manual'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('describes current sections in the system prompt', function () { - $bertaRoot = sys_get_temp_dir() . '/berta_ai_sections_' . uniqid(); - File::ensureDirectoryExists($bertaRoot . '/storage'); - config(['app.old_berta_root' => $bertaRoot]); - file_put_contents( - $bertaRoot . '/storage/sections.xml', - '' - . '
<![CDATA[Works]]>
' - . '
<![CDATA[Shop]]>' - . '
' - . '
<![CDATA[About]]>
' - . '
', - ); - - try { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'buildSectionsSection'); - - $result = $method->invoke($controller, (new SiteContentReader)->sections()); - } finally { - File::deleteDirectory($bertaRoot); - } - - expect($result)->toContain( - "### Current Sections\n" - . " order 0: \"Works\" (name: works, type: default, published: 1)\n" - . " order 1: \"Shop\" (name: shop, type: shop, published: 0)\n" - . " SEO — title: \"Buy prints\", keywords: \"prints, art\", description: \"Prints shop\"\n" - . " order 2: \"About\" (name: about, type: default, published: 1)\n" - . "\n### Operations", - ); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('describes current site settings in the system prompt', function () { - $bertaRoot = sys_get_temp_dir() . '/berta_ai_settings_' . uniqid(); - File::ensureDirectoryExists($bertaRoot . '/storage'); - linkBertaEngine($bertaRoot); - config(['app.old_berta_root' => $bertaRoot]); - file_put_contents( - $bertaRoot . '/storage/settings.xml', - '' - . '' - . '' - . '' - . '', - ); - - try { - $controller = new AiChatController; - $method = new ReflectionMethod($controller, 'formatCurrentSettings'); - - $result = $method->invoke($controller, (new SiteSettingsDataService(''))->getState(), (new SiteSettingsConfigService)->get()); - } finally { - File::deleteDirectory($bertaRoot); - } - - expect($result)->toContain('texts (') - ->and($result)->toContain(' pageTitle (') - ->and($result)->toContain(' unknownKey: kept') - ->and($result)->not->toContain('https://x.com') - ->and($result)->toMatchSnapshot(); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/GetEntryContentToolTest.php b/_api_app/tests/Feature/GetEntryContentToolTest.php deleted file mode 100644 index 18a58f9ea..000000000 --- a/_api_app/tests/Feature/GetEntryContentToolTest.php +++ /dev/null @@ -1,53 +0,0 @@ -bertaRoot = sys_get_temp_dir() . '/berta_chat_content_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - // The default template shows entry titles. - linkBertaEngine($this->bertaRoot); - writeTemplate($this->bertaRoot, 'default'); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '<![CDATA[First]]>' - . '<![CDATA[Second]]>Hello

]]>
' - . '
', - ); -}); - -afterEach(function () { - File::deleteDirectory($this->bertaRoot); -}); - -it('returns the content of an entry for the ai chat', function () { - $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2'])); - - expect(json_decode($result, true))->toBe([ - 'id' => '2', 'title' => 'Second', 'description' => '

Hello

', 'order' => 1, - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('tells the ai chat when the entry does not exist', function () { - $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99'])); - - expect($result)->toBe('Entry not found.'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('leaves out the title where the template has no entry titles', function () { - writeTemplate($this->bertaRoot, 'messy-0.4.2'); - - $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2'])); - - expect(json_decode($result, true))->toBe(['id' => '2', 'description' => '

Hello

', 'order' => 1]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/GetEntryGalleryToolTest.php b/_api_app/tests/Feature/GetEntryGalleryToolTest.php deleted file mode 100644 index 5b4bc0518..000000000 --- a/_api_app/tests/Feature/GetEntryGalleryToolTest.php +++ /dev/null @@ -1,50 +0,0 @@ -bertaRoot = sys_get_temp_dir() . '/berta_chat_gallery_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '' - . 'Sunset

]]>
' - . '' - . '
', - ); -}); - -afterEach(function () { - File::deleteDirectory($this->bertaRoot); -}); - -it('describes the gallery of an entry for the ai chat', function () { - $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '5'])); - - expect(json_decode($result, true))->toBe([ - 'settings' => ['type' => 'slideshow', 'autoplay' => '3'], - 'file_count' => 2, - 'image_count' => 1, - 'video_count' => 1, - 'files' => [ - ['type' => 'image', 'extension' => 'jpg', 'src' => 'Photo.JPG', 'caption' => '

Sunset

'], - ['type' => 'video', 'extension' => 'mp4', 'src' => 'clip.mp4', 'caption' => ''], - ], - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('tells the ai chat when the entry does not exist', function () { - $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99'])); - - expect($result)->toBe('Entry not found.'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/ListSectionEntriesToolTest.php b/_api_app/tests/Feature/ListSectionEntriesToolTest.php deleted file mode 100644 index dbe9409a7..000000000 --- a/_api_app/tests/Feature/ListSectionEntriesToolTest.php +++ /dev/null @@ -1,55 +0,0 @@ -bertaRoot = sys_get_temp_dir() . '/berta_chat_entries_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); - // The default template shows entry titles. - linkBertaEngine($this->bertaRoot); - writeTemplate($this->bertaRoot, 'default'); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); -}); - -afterEach(function () { - File::deleteDirectory($this->bertaRoot); -}); - -it('lists section entries for the ai chat', function () { - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '' - . '' - . '<![CDATA[First]]>Hello

]]>
' - . '' - . '
', - ); - - $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works'])); - - expect(json_decode($result, true))->toBe([ - [ - 'id' => '1', 'title' => 'First', 'description' => '

Hello

', 'order' => 0, - 'gallery' => ['type' => 'slideshow', 'file_count' => 3, 'image_count' => 2, 'video_count' => 1], - ], - [ - 'id' => '2', 'title' => '', 'description' => '', 'order' => 1, - 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], - ], - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('tells the ai chat when a section has no entries', function () { - $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works'])); - - expect($result)->toBe('No entries found in this section.'); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php deleted file mode 100644 index b4413889e..000000000 --- a/_api_app/tests/Feature/SiteContentReaderTest.php +++ /dev/null @@ -1,253 +0,0 @@ -bertaRoot = sys_get_temp_dir() . '/berta_reader_' . uniqid(); - File::ensureDirectoryExists($this->bertaRoot . '/storage'); - config(['app.old_berta_root' => $this->bertaRoot]); -}); - -afterEach(function () { - File::deleteDirectory($this->bertaRoot); -}); - -it('normalizes sections from storage', function () { - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '' - . '
<![CDATA[Works]]>
' - . '
' - . '
' - . '
<![CDATA[About]]>
' - . '
', - ); - - expect((new SiteContentReader)->sections())->toBe([ - [ - 'name' => 'works', 'title' => 'Works', 'type' => 'default', 'published' => true, 'entry_count' => 3, 'order' => 0, - 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], - ], - [ - 'name' => 'shop', 'title' => '', 'type' => 'shop', 'published' => false, 'entry_count' => 2, 'order' => 1, - 'seo' => ['title' => 'Buy prints', 'keywords' => '', 'description' => ''], - ], - [ - 'name' => 'about', 'title' => 'About', 'type' => 'default', 'published' => true, 'entry_count' => 0, 'order' => 2, - 'seo' => ['title' => '', 'keywords' => '', 'description' => ''], - ], - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -it('returns no sections when the site has no sections file', function () { - expect((new SiteContentReader)->sections())->toBe([]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('entries', function () { - beforeEach(function () { - // Site settings (for the template) read the engine's setting definitions. - linkBertaEngine($this->bertaRoot); - writeTemplate($this->bertaRoot, 'default'); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - }); - - it('normalizes entries of a section', function () { - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '' - . '' - . '<![CDATA[Series]]>Text

]]>
' - . '' - . '
', - ); - - expect((new SiteContentReader)->entries('works'))->toBe([ - [ - 'id' => '7', 'title' => 'Series', 'description' => '

Text

', 'order' => 0, - 'gallery' => ['type' => 'row', 'file_count' => 2, 'image_count' => 1, 'video_count' => 1], - ], - [ - 'id' => '8', 'title' => '', 'description' => '', 'order' => 1, - 'gallery' => ['type' => 'slideshow', 'file_count' => 0, 'image_count' => 0, 'video_count' => 0], - ], - ]); - }); - - it('leaves out entry titles where the template does not show them', function () { - writeTemplate($this->bertaRoot, 'messy-0.4.2'); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '<![CDATA[Hidden]]>', - ); - - $reader = new SiteContentReader; - - expect($reader->showsEntryTitles('works'))->toBeFalse() - ->and($reader->entries('works')[0])->not->toHaveKey('title'); - }); - - it('keeps entry titles in portfolio sections on any template', function () { - writeTemplate($this->bertaRoot, 'messy-0.4.2'); - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '<![CDATA[Project]]>', - ); - - $reader = new SiteContentReader; - - expect($reader->showsEntryTitles('works'))->toBeTrue() - ->and($reader->entries('works')[0]['title'])->toBe('Project'); - }); - - it('knows which sections exist', function () { - $reader = new SiteContentReader; - - expect($reader->hasSection('works'))->toBeTrue() - ->and($reader->hasSection('missing'))->toBeFalse() - ->and($reader->showsEntryTitles('missing'))->toBeFalse(); - }); - - it('reads one entry like the entry list', function () { - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '<![CDATA[First]]>', - ); - $reader = new SiteContentReader; - - expect($reader->entryContent('works', '1'))->toBe($reader->entries('works')[0]) - ->and($reader->entryContent('works', '99'))->toBeNull() - ->and($reader->entryContent('missing', '1'))->toBeNull() - ->and($reader->entryContent('../storage/blog.works', '1'))->toBeNull(); - }); - - it('reads no entries for sections that do not exist', function (string $section) { - file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1'); - - expect((new SiteContentReader)->entries($section))->toBe([]); - })->with(['missing', '../storage/blog.missing', '']); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -// File type, extension and caption normalization is pinned in GetEntryGalleryToolTest. -describe('entry gallery', function () { - beforeEach(function () { - file_put_contents( - $this->bertaRoot . '/storage/sections.xml', - '
<![CDATA[Works]]>
', - ); - file_put_contents( - $this->bertaRoot . '/storage/blog.works.xml', - '' - . '' - . '' - . '', - ); - }); - - it('treats files without a type as images', function () { - $gallery = (new SiteContentReader)->entryGallery('works', '1'); - - expect($gallery['files'])->toBe([['type' => 'image', 'extension' => 'png', 'src' => 'untyped.png', 'caption' => '']]) - ->and($gallery['image_count'])->toBe(1); - }); - - it('describes an entry without gallery as empty', function () { - expect((new SiteContentReader)->entryGallery('works', '2'))->toBe([ - 'settings' => [], 'file_count' => 0, 'image_count' => 0, 'video_count' => 0, 'files' => [], - ]); - }); - - it('finds no gallery for unknown sections or entries', function (string $section, string $entryId) { - expect((new SiteContentReader)->entryGallery($section, $entryId))->toBeNull(); - })->with([ - 'unknown entry' => ['works', '99'], - 'unknown section' => ['missing', '1'], - 'path outside storage' => ['../storage/blog.works', '1'], - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('site settings', function () { - beforeEach(function () { - // Site settings read the engine's setting definitions. - linkBertaEngine($this->bertaRoot); - file_put_contents( - $this->bertaRoot . '/storage/settings.xml', - '' - . '' - . '' - . '' - . '' - . '', - ); - }); - - it('describes settings with their titles and leaves out the internal group', function () { - $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name'); - $texts = collect($groups['texts']['settings'])->keyBy('key'); - - expect($groups->keys())->not->toContain('berta') - // Not on the settings page, but real site content: the site heading and footer texts. - ->and(collect($groups['siteTexts']['settings'])->keyBy('key')['siteHeading']['value'])->toBe('Acme') - ->and($groups['texts']['title'])->toBeString() - ->and($texts['pageTitle']['value'])->toBe('My site') - ->and($texts['pageTitle']['title'])->toBeString() - ->and($texts['pageTitle']['items'])->toBeNull() - ->and($texts['unknownKey'])->toBe(['key' => 'unknownKey', 'title' => null, 'description' => null, 'value' => 'kept', 'items' => null]); - }); - - it('lists the entries of list settings as items', function () { - $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name'); - $links = collect($groups['socialMediaLinks']['settings'])->keyBy('key')['links']; - - expect($links['value'])->toBe('') - ->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]); - }); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); - -describe('design settings', function () { - beforeEach(function () { - // Design settings read the template's setting definitions. - linkBertaEngine($this->bertaRoot); - }); - - it('describes the active template settings with their titles', function () { - writeTemplate($this->bertaRoot, 'messy-0.4.2'); - file_put_contents( - $this->bertaRoot . '/storage/settings.messy.xml', - '', - ); - - $design = (new SiteContentReader)->designSettings(); - $groups = collect($design['groups'])->keyBy('name'); - $color = collect($groups['generalFontSettings']['settings'])->keyBy('key')['color']; - - expect($design['template'])->toBe('messy-0.4.2') - ->and($groups['generalFontSettings']['title'])->toBeString() - ->and($color['value'])->toBe('#ff0000') - ->and($color['title'])->toBeString() - // Settings the site never changed come with the template's defaults. - ->and($groups['menu']['settings'])->not->toBeEmpty(); - }); - - it('reads the installed template when the site names an unknown one', function (string $template) { - writeTemplate($this->bertaRoot, $template); - - $design = (new SiteContentReader)->designSettings(); - - expect($design['template'])->toBe('messy-0.4.2') - ->and($design['groups'])->not->toBeEmpty(); - })->with([ - 'older version' => 'messy-0.3.0', - 'not installed' => 'missing-1.0.0', - ]); -})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index 70059498c..c3b376fbb 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -16,7 +16,8 @@ | */ -pest()->extend(TestCase::class)->in('Feature'); +// Plugins (separate repositories in app/Plugins) keep their own tests in their tests folder. +pest()->extend(TestCase::class)->in('Feature', '../app/Plugins/*/tests'); /* |-------------------------------------------------------------------------- diff --git a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php new file mode 100644 index 000000000..e095d3710 --- /dev/null +++ b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php @@ -0,0 +1,13 @@ +toBe($shown); +})->with([ + 'default template' => ['default', 'default', true], + 'default template, portfolio section' => ['default', 'portfolio', true], + 'other template, portfolio section' => ['messy', 'portfolio', true], + 'other template' => ['messy', 'default', false], + 'other template, no section type' => ['messy', null, false], +]); From b9c605b255e694b7dcc4a6e5d61c5bbc48e50d5a Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 27 Oct 2026 16:20:50 +0200 Subject: [PATCH 08/15] Share the plan features test helper with all tests givePlanFeatures() puts a test website on a hosting plan with the given features and limits, as UserModel reads them. Co-Authored-By: Claude Opus 5.5 --- _api_app/tests/Pest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index c3b376fbb..fa41dee19 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -87,6 +87,22 @@ function linkBertaEngine(string $bertaRoot): void } } +/** + * Puts the website in a temporary Berta root on a hosting plan with these features, as UserModel reads them. + * Needs linkBertaEngine(). + * + * @param list $features + * @param array $limits e.g. ['ai_assistant_daily' => 10] + */ +function givePlanFeatures(string $bertaRoot, array $features, array $limits = []): void +{ + File::ensureDirectoryExists($bertaRoot . '/engine/config'); + File::put($bertaRoot . '/engine/config/inc.conf.php', " 'https://berta.me/login', 'plans' => [['id' => '2', 'features' => $features, 'limits' => $limits]]])); + File::put($bertaRoot . '/engine/plan', '2'); +} + /** * Sets the main site's template in a temporary Berta root. Needs linkBertaEngine() to be read. */ From 94998cf8ac6bd07ee464938542d49c7821612a17 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 27 Oct 2026 19:45:21 +0200 Subject: [PATCH 09/15] Validate setting values on the server, as the editor does The editor's settings and design endpoints saved any value they were sent. They now check it against the setting's definition with the editor's own rules (allowed values, colors, CSS units, blank values, validation kinds) and the plan feature a setting needs, and answer 422 with error_message otherwise. Entries of list settings are left as before. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/SettingValueValidator.php | 101 ++++++++++++++++++ .../Sites/Settings/SiteSettingsController.php | 8 ++ .../SiteTemplateSettingsController.php | 8 ++ .../tests/Feature/SettingsValidationTest.php | 69 ++++++++++++ .../tests/Unit/SettingValueValidatorTest.php | 51 +++++++++ 5 files changed, 237 insertions(+) create mode 100644 _api_app/app/Configuration/SettingValueValidator.php create mode 100644 _api_app/tests/Feature/SettingsValidationTest.php create mode 100644 _api_app/tests/Unit/SettingValueValidatorTest.php diff --git a/_api_app/app/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php new file mode 100644 index 000000000..d685e8bd7 --- /dev/null +++ b/_api_app/app/Configuration/SettingValueValidator.php @@ -0,0 +1,101 @@ +|null $features the website's plan features; read from the account when needed if null + */ + public function __construct(private ?array $features = null) {} + + /** + * Why a value sent for a setting path can't be saved, or null when it can. Paths without a definition (e.g. + * entries of list settings) and values that aren't text are left to the data service, as before. + * + * @param mixed $definition the setting's definition, if the path has one + */ + public function errorFor(mixed $value, mixed $definition): ?string + { + return is_array($definition) && is_scalar($value) ? $this->error(trim((string) $value), $definition) : null; + } + + /** + * Why the value can't be saved, or null when it can. + * + * @param array $definition the setting's definition + */ + public function error(string $value, array $definition): ?string + { + if (isset($definition['requires_feature']) && ! in_array($definition['requires_feature'], $this->features ??= (new UserModel)->features, true)) { + return "This setting needs the {$definition['requires_feature']} feature, which the website's plan doesn't include."; + } + + return match ($definition['format'] ?? 'text') { + 'select', 'fontselect' => $this->selectError($value, $definition), + 'color' => $this->colorError($value, $definition), + 'text' => $this->textError($value, $definition), + default => null, + }; + } + + /** + * @param array $definition + */ + private function selectError(string $value, array $definition): ?string + { + $values = $definition['values'] ?? []; + $allowed = array_map('strval', array_is_list($values) ? $values : array_keys($values)); + + return in_array($value, $allowed, true) ? null : 'Use one of the allowed values: ' . implode(' | ', $allowed) . '.'; + } + + /** + * The editor's color picker saves rgba(); stored values also use #hex and rgb(). + * + * @param array $definition + */ + private function colorError(string $value, array $definition): ?string + { + if ($value === '') { + return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.'; + } + + return preg_match('/^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\))$/i', $value) + ? null + : 'Use a color like #ff0000 or rgb(255,0,0).'; + } + + /** + * The rules of the editor's text inputs (validation.service.ts). + * + * @param array $definition + */ + private function textError(string $value, array $definition): ?string + { + if ($value === '') { + return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.'; + } + + if (! empty($definition['css_units']) && ! preg_match('/^-?\d*\.?\d+(px|em|rem|vw|vh|pt|%)$/', $value)) { + return 'Use a number with a CSS unit: px, em, rem, vw, vh, pt or %.'; + } + + return match ($definition['validation'] ?? null) { + 'positive_integer' => preg_match('/^[1-9]\d*$/', $value) ? null : 'Use a positive whole number.', + 'zero_or_positive_integer' => preg_match('/^\d+$/', $value) ? null : 'Use zero or a positive whole number.', + 'positive_number' => preg_match('/^(?!0$)(?!0\d+$)\d*\.?\d+$/', $value) ? null : 'Use a positive number.', + 'email' => preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $value) ? null : 'Use a valid email address.', + default => null, + }; + } +} diff --git a/_api_app/app/Sites/Settings/SiteSettingsController.php b/_api_app/app/Sites/Settings/SiteSettingsController.php index 8418f5444..12517e8a4 100644 --- a/_api_app/app/Sites/Settings/SiteSettingsController.php +++ b/_api_app/app/Sites/Settings/SiteSettingsController.php @@ -2,6 +2,8 @@ namespace App\Sites\Settings; +use App\Configuration\SettingValueValidator; +use App\Configuration\SiteSettingsConfigService; use App\Http\Controllers\Controller; use App\Shared\Helpers; use Illuminate\Http\Request; @@ -45,6 +47,12 @@ public function update(Request $request) $site = $path_arr[0]; $settingsDataService = new SiteSettingsDataService($site); + // "/settings//"; deeper paths are entries of list settings + $definition = count($path_arr) === 4 && $path_arr[3] !== '_' ? ((new SiteSettingsConfigService)->get()[$path_arr[2]][$path_arr[3]] ?? null) : null; + if ($error = (new SettingValueValidator)->errorFor($json['value'], $definition)) { + return response()->json(['path' => $json['path'], 'value' => $json['value'], 'error_message' => $error], 422); + } + $res = $settingsDataService->saveValueByPath($json['path'], $json['value']); // @@@:TODO: Replace this with something sensible, when migration to redux is done // `real` returns the user input value diff --git a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php index 9f243bd98..4ea3f9a4c 100644 --- a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php +++ b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php @@ -2,6 +2,8 @@ namespace App\Sites\TemplateSettings; +use App\Configuration\SettingValueValidator; +use App\Configuration\SiteTemplatesConfigService; use App\Http\Controllers\Controller; use App\Shared\Helpers; use Illuminate\Http\Request; @@ -17,6 +19,12 @@ public function update(Request $request) $template = $path_arr[2]; $templateSettingsDataService = new SiteTemplateSettingsDataService($site, $template); + // "/template-settings/