diff --git a/.gitignore b/.gitignore
index 8affbb6..d027d61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -156,6 +156,7 @@ vendor/
composer.lock
*.phar
codeception.phar
+.phpunit.cache/
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
diff --git a/composer.json b/composer.json
index c21717f..a5fd414 100644
--- a/composer.json
+++ b/composer.json
@@ -15,9 +15,23 @@
"php": ">=8.1"
},
"require-dev": {
- "squizlabs/php_codesniffer": "^3.7",
+ "joomla/coding-standards": "^3.0",
"phpstan/phpstan": "^1.10",
- "joomla/coding-standards": "^3.0"
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "autoload": {
+ "psr-4": {
+ "Joomla\\Plugin\\System\\MokoOG\\": "source/packages/plg_system_mokoog/src/",
+ "Joomla\\Plugin\\Content\\MokoOG\\": "source/packages/plg_content_mokoog/src/",
+ "Joomla\\Plugin\\WebServices\\MokoOG\\": "source/packages/plg_webservices_mokoog/src/",
+ "Joomla\\Component\\MokoOG\\Administrator\\": "source/packages/com_mokoog/src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Mokoconsulting\\MokoOG\\Tests\\": "tests/"
+ }
},
"minimum-stability": "alpha",
"prefer-stable": true,
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..9f4fb2a
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests/Unit
+
+
+
+
+ source/packages
+
+
+
diff --git a/tests/Unit/Helper/JsonLdBuilderTest.php b/tests/Unit/Helper/JsonLdBuilderTest.php
new file mode 100644
index 0000000..e7b3921
--- /dev/null
+++ b/tests/Unit/Helper/JsonLdBuilderTest.php
@@ -0,0 +1,257 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Mokoconsulting\MokoOG\Tests\Unit\Helper;
+
+use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
+use PHPUnit\Framework\TestCase;
+
+class JsonLdBuilderTest extends TestCase
+{
+ // ── FAQPage ──────────────────────────────────────────────────────
+
+ public function testBuildFaqReturnsNullForEmptyArray(): void
+ {
+ $this->assertNull(JsonLdBuilder::buildFaq([]));
+ }
+
+ public function testBuildFaqSkipsEmptyQuestions(): void
+ {
+ $faqs = [
+ ['question' => '', 'answer' => 'An answer'],
+ ['question' => 'Valid?', 'answer' => ''],
+ ['question' => ' ', 'answer' => 'Still empty'],
+ ];
+
+ $this->assertNull(JsonLdBuilder::buildFaq($faqs));
+ }
+
+ public function testBuildFaqReturnsValidSchema(): void
+ {
+ $faqs = [
+ ['question' => 'What is OG?', 'answer' => 'Open Graph protocol.'],
+ ['question' => 'Why use it?', 'answer' => 'Better social previews.'],
+ ];
+
+ $result = JsonLdBuilder::buildFaq($faqs);
+
+ $this->assertNotNull($result);
+ $this->assertSame('https://schema.org', $result['@context']);
+ $this->assertSame('FAQPage', $result['@type']);
+ $this->assertCount(2, $result['mainEntity']);
+ $this->assertSame('Question', $result['mainEntity'][0]['@type']);
+ $this->assertSame('What is OG?', $result['mainEntity'][0]['name']);
+ $this->assertSame('Open Graph protocol.', $result['mainEntity'][0]['acceptedAnswer']['text']);
+ }
+
+ // ── HowTo ────────────────────────────────────────────────────────
+
+ public function testBuildHowToReturnsNullForEmptySteps(): void
+ {
+ $this->assertNull(JsonLdBuilder::buildHowTo('Test', []));
+ $this->assertNull(JsonLdBuilder::buildHowTo('Test', ['', ' ']));
+ }
+
+ public function testBuildHowToReturnsValidSchema(): void
+ {
+ $result = JsonLdBuilder::buildHowTo('Install Joomla', ['Download ZIP', 'Upload files', 'Run installer']);
+
+ $this->assertNotNull($result);
+ $this->assertSame('HowTo', $result['@type']);
+ $this->assertSame('Install Joomla', $result['name']);
+ $this->assertCount(3, $result['step']);
+ $this->assertSame(1, $result['step'][0]['position']);
+ $this->assertSame('HowToStep', $result['step'][0]['@type']);
+ $this->assertSame('Download ZIP', $result['step'][0]['text']);
+ $this->assertArrayNotHasKey('image', $result);
+ }
+
+ public function testBuildHowToIncludesImageWhenProvided(): void
+ {
+ $result = JsonLdBuilder::buildHowTo('Fix a bike', ['Remove wheel'], 'https://example.com/bike.jpg');
+
+ $this->assertNotNull($result);
+ $this->assertSame('https://example.com/bike.jpg', $result['image']);
+ }
+
+ // ── Recipe ───────────────────────────────────────────────────────
+
+ public function testBuildRecipeReturnsNullWhenNoData(): void
+ {
+ $data = (object) ['name' => '', 'description' => ''];
+
+ $this->assertNull(JsonLdBuilder::buildRecipe($data));
+ }
+
+ public function testBuildRecipeCalculatesTotalTime(): void
+ {
+ $data = (object) [
+ 'name' => 'Pasta',
+ 'prepTime' => 'PT15M',
+ 'cookTime' => 'PT30M',
+ ];
+
+ $result = JsonLdBuilder::buildRecipe($data);
+
+ $this->assertNotNull($result);
+ $this->assertSame('Recipe', $result['@type']);
+ $this->assertSame('PT45M', $result['totalTime']);
+ }
+
+ public function testBuildRecipeSplitsIngredientsByNewline(): void
+ {
+ $data = (object) [
+ 'name' => 'Salad',
+ 'ingredients' => "Lettuce\nTomato\nOnion",
+ ];
+
+ $result = JsonLdBuilder::buildRecipe($data);
+
+ $this->assertNotNull($result);
+ $this->assertSame(['Lettuce', 'Tomato', 'Onion'], $result['recipeIngredient']);
+ }
+
+ // ── Event ────────────────────────────────────────────────────────
+
+ public function testBuildEventReturnsNullWithoutStartDate(): void
+ {
+ $data = (object) ['name' => 'Conference', 'startDate' => ''];
+
+ $this->assertNull(JsonLdBuilder::buildEvent($data));
+ }
+
+ public function testBuildEventIncludesLocationAndOffers(): void
+ {
+ $data = (object) [
+ 'name' => 'Tech Summit',
+ 'startDate' => '2026-09-01T09:00:00',
+ 'endDate' => '2026-09-01T17:00:00',
+ 'location' => (object) [
+ 'name' => 'Convention Center',
+ 'address' => '123 Main St',
+ ],
+ 'offers' => (object) [
+ 'price' => '99.00',
+ 'currency' => 'EUR',
+ 'url' => 'https://example.com/tickets',
+ ],
+ ];
+
+ $result = JsonLdBuilder::buildEvent($data);
+
+ $this->assertNotNull($result);
+ $this->assertSame('Event', $result['@type']);
+ $this->assertSame('2026-09-01T09:00:00', $result['startDate']);
+ $this->assertSame('2026-09-01T17:00:00', $result['endDate']);
+ $this->assertSame('Place', $result['location']['@type']);
+ $this->assertSame('Convention Center', $result['location']['name']);
+ $this->assertSame('Offer', $result['offers']['@type']);
+ $this->assertSame('99.00', $result['offers']['price']);
+ $this->assertSame('EUR', $result['offers']['priceCurrency']);
+ }
+
+ // ── LocalBusiness ────────────────────────────────────────────────
+
+ public function testBuildLocalBusinessReturnsNullWithoutName(): void
+ {
+ $params = $this->createParamsMock([]);
+
+ $this->assertNull(JsonLdBuilder::buildLocalBusiness($params));
+ }
+
+ public function testBuildLocalBusinessIncludesAddress(): void
+ {
+ $params = $this->createParamsMock([
+ 'business_name' => 'Moko Consulting',
+ 'street_address' => '456 Oak Ave',
+ 'city' => 'Austin',
+ 'region' => 'TX',
+ 'postal_code' => '78701',
+ 'country' => 'US',
+ 'telephone' => '+1-555-0100',
+ ]);
+
+ $result = JsonLdBuilder::buildLocalBusiness($params);
+
+ $this->assertNotNull($result);
+ $this->assertSame('LocalBusiness', $result['@type']);
+ $this->assertSame('Moko Consulting', $result['name']);
+ $this->assertSame('PostalAddress', $result['address']['@type']);
+ $this->assertSame('456 Oak Ave', $result['address']['streetAddress']);
+ $this->assertSame('Austin', $result['address']['addressLocality']);
+ $this->assertSame('TX', $result['address']['addressRegion']);
+ $this->assertSame('78701', $result['address']['postalCode']);
+ $this->assertSame('US', $result['address']['addressCountry']);
+ $this->assertSame('+1-555-0100', $result['telephone']);
+ }
+
+ // ── VideoObject ──────────────────────────────────────────────────
+
+ public function testBuildVideoReturnsNullForEmptyUrl(): void
+ {
+ $this->assertNull(JsonLdBuilder::buildVideo(''));
+ }
+
+ public function testBuildVideoAddsEmbedUrlForYoutube(): void
+ {
+ $result = JsonLdBuilder::buildVideo(
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ 'Test Video',
+ 'A description'
+ );
+
+ $this->assertNotNull($result);
+ $this->assertSame('VideoObject', $result['@type']);
+ $this->assertSame('https://www.youtube.com/watch?v=dQw4w9WgXcQ', $result['contentUrl']);
+ $this->assertSame('https://www.youtube.com/embed/dQw4w9WgXcQ', $result['embedUrl']);
+ $this->assertSame('Test Video', $result['name']);
+ }
+
+ // ── toScriptTag ──────────────────────────────────────────────────
+
+ public function testToScriptTagEscapesClosingScriptTags(): void
+ {
+ $schema = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ 'headline' => 'Test ',
+ ];
+
+ $output = JsonLdBuilder::toScriptTag($schema);
+
+ $this->assertStringStartsWith('', $output);
+ // The closing inside the JSON must be escaped
+ $this->assertStringNotContainsString('