From cbebaecc225dec1e629ab423ff387851c0ea9b42 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 12:31:18 -0500 Subject: [PATCH] test: add PHPUnit test suite with JsonLdBuilder unit tests 16 unit tests covering FAQ, HowTo, Event, Recipe, LocalBusiness, and VideoObject schema builders plus toScriptTag XSS escaping. Closes #75 --- .gitignore | 1 + composer.json | 18 +- phpunit.xml.dist | 17 ++ tests/Unit/Helper/JsonLdBuilderTest.php | 257 ++++++++++++++++++++++++ tests/bootstrap.php | 13 ++ 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Unit/Helper/JsonLdBuilderTest.php create mode 100644 tests/bootstrap.php 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('