', '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',
+ '',
+ );
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('lists section entries for the ai chat', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ''
+ . ''
+ . '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',
+ '',
+ );
+ });
+
+ it('normalizes entries of a section', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ''
+ . ''
+ . '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',
+ '',
+ );
+
+ $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',
+ '',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ '',
+ );
+
+ $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',
+ '',
+ );
+ 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',
+ '',
+ );
+ 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',
+ '',
+ );
+ 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',
+ '',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ''
+ . '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',
+ '',
+ );
+ $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',
- ''
- . ''
- . '',
- );
-
- 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',
- '',
- );
-
- // 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',
- ''
- . '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',
- '',
- );
- 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": "
', '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' => '