feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\AccessibleLink;
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Core\ValueObjects\LinkRel;
use App\Framework\Core\ValueObjects\LinkTarget;
describe('AccessibleLink Value Object', function () {
it('can create from HtmlLink', function () {
$htmlLink = HtmlLink::create('https://example.com', 'Example');
$accessibleLink = AccessibleLink::fromHtmlLink($htmlLink);
expect($accessibleLink->baseLink)->toBe($htmlLink)
->and($accessibleLink->ariaLabel)->toBeNull();
});
it('can create with aria-label', function () {
$link = AccessibleLink::create(
'https://example.com',
'Example',
'Visit Example Website'
);
expect($link->ariaLabel)->toBe('Visit Example Website')
->and($link->baseLink->text)->toBe('Example');
});
it('can create current page link', function () {
$link = AccessibleLink::currentPage('/about', 'About Us');
expect($link->isCurrent())->toBeTrue()
->and($link->ariaCurrent)->toBeTrue()
->and($link->ariaCurrentValue)->toBe('page')
->and($link->tabindex)->toBe(-1); // Current page not focusable
});
it('can create skip link', function () {
$link = AccessibleLink::skipLink('main-content', 'Skip to main');
expect($link->getHref())->toBe('#main-content')
->and($link->ariaLabel)->toBe('Skip to main')
->and($link->baseLink->cssClass)->toContain('skip-link');
});
it('can create external accessible link', function () {
$link = AccessibleLink::external('https://external.com', 'External Site');
expect($link->baseLink->isExternal())->toBeTrue()
->and($link->baseLink->opensNewWindow())->toBeTrue()
->and($link->ariaLabel)->toContain('opens in new window');
});
it('renders HTML with ARIA attributes', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::create('https://example.com', 'Example'),
ariaLabel: 'Visit Example',
ariaDescribedBy: 'example-description',
ariaCurrent: true
);
$html = $link->toHtml();
expect($html)->toContain('aria-label="Visit Example"')
->and($html)->toContain('aria-describedby="example-description"')
->and($html)->toContain('aria-current="page"');
});
it('renders disabled link with proper ARIA', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::create('/disabled', 'Disabled'),
ariaDisabled: true,
tabindex: -1
);
$html = $link->toHtml();
expect($html)->toContain('aria-disabled="true"')
->and($html)->toContain('tabindex="-1"');
});
it('supports immutable withers', function () {
$original = AccessibleLink::create('https://example.com', 'Original');
$modified = $original
->withAriaLabel('Modified Label')
->withAriaCurrent(true, 'step')
->withTabindex(0);
expect($original->ariaLabel)->toBeNull()
->and($original->ariaCurrent)->toBeFalse()
->and($modified->ariaLabel)->toBe('Modified Label')
->and($modified->ariaCurrent)->toBeTrue()
->and($modified->ariaCurrentValue)->toBe('step')
->and($modified->tabindex)->toBe(0);
});
it('sets default aria-current value to page', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::create('/page', 'Page'),
ariaCurrent: true
);
expect($link->ariaCurrentValue)->toBe('page');
});
it('can override aria-current value', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::create('/step2', 'Step 2'),
ariaCurrent: true,
ariaCurrentValue: 'step'
);
expect($link->ariaCurrentValue)->toBe('step');
});
it('provides accessibility info', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::external('https://external.com', 'External'),
ariaLabel: 'Visit External Site (opens new window)',
ariaCurrent: false,
ariaDisabled: false
);
$info = $link->getAccessibilityInfo();
expect($info['has_aria_label'])->toBeTrue()
->and($info['is_current'])->toBeFalse()
->and($info['is_disabled'])->toBeFalse()
->and($info['is_focusable'])->toBeTrue()
->and($info['opens_new_window'])->toBeTrue()
->and($info['is_external'])->toBeTrue();
});
it('escapes ARIA attribute values', function () {
$link = new AccessibleLink(
baseLink: HtmlLink::create('/page', 'Page'),
ariaLabel: 'Test "quotes" & <tags>'
);
$html = $link->toHtml();
expect($html)->toContain('&quot;')
->and($html)->toContain('&amp;')
->and($html)->not->toContain('<tags>');
});
it('disabling sets tabindex to -1', function () {
$link = AccessibleLink::create('/page', 'Page', 'Page Label')
->withTabindex(0)
->withAriaDisabled(true);
expect($link->tabindex)->toBe(-1);
});
});
describe('LinkRel SEO & Pagination', function () {
it('detects SEO-related rels', function () {
expect(LinkRel::CANONICAL->isSeoRelated())->toBeTrue()
->and(LinkRel::AMPHTML->isSeoRelated())->toBeTrue()
->and(LinkRel::RSS->isSeoRelated())->toBeTrue()
->and(LinkRel::NOFOLLOW->isSeoRelated())->toBeFalse();
});
it('detects pagination rels', function () {
expect(LinkRel::NEXT->isPagination())->toBeTrue()
->and(LinkRel::PREV->isPagination())->toBeTrue()
->and(LinkRel::FIRST->isPagination())->toBeTrue()
->and(LinkRel::LAST->isPagination())->toBeTrue()
->and(LinkRel::CANONICAL->isPagination())->toBeFalse();
});
it('can create pagination links', function () {
$nextLink = new HtmlLink(
href: '/page/2',
text: 'Next Page',
rel: [LinkRel::NEXT]
);
$prevLink = new HtmlLink(
href: '/page/1',
text: 'Previous Page',
rel: [LinkRel::PREV]
);
expect($nextLink->rel)->toContain(LinkRel::NEXT)
->and($prevLink->rel)->toContain(LinkRel::PREV);
});
});

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Core\ValueObjects\LinkRel;
use App\Framework\Core\ValueObjects\LinkTarget;
describe('HtmlLink Value Object', function () {
it('can create simple link', function () {
$link = HtmlLink::create('https://example.com', 'Example');
expect($link->getHref())->toBe('https://example.com')
->and($link->text)->toBe('Example')
->and($link->target)->toBeNull()
->and($link->rel)->toBeEmpty();
});
it('can create external link with security attributes', function () {
$link = HtmlLink::external('https://external.com', 'External Site');
expect($link->isExternal())->toBeTrue()
->and($link->opensNewWindow())->toBeTrue()
->and($link->target)->toBe(LinkTarget::BLANK)
->and($link->rel)->toContain(LinkRel::NOOPENER)
->and($link->rel)->toContain(LinkRel::NOREFERRER)
->and($link->rel)->toContain(LinkRel::EXTERNAL);
});
it('can create download link', function () {
$link = HtmlLink::download('/files/document.pdf', 'Download PDF', 'my-document.pdf');
expect($link->isDownload())->toBeTrue()
->and($link->download)->toBe('my-document.pdf')
->and($link->text)->toBe('Download PDF');
});
it('can create mailto link', function () {
$link = HtmlLink::mailto('test@example.com', 'Email Us');
expect($link->getHref())->toBe('mailto:test@example.com')
->and($link->text)->toBe('Email Us');
});
it('can create tel link', function () {
$link = HtmlLink::tel('+1 (555) 123-4567', 'Call Us');
expect($link->getHref())->toBe('tel:+15551234567')
->and($link->text)->toBe('Call Us');
});
it('renders as HTML anchor tag', function () {
$link = new HtmlLink(
href: 'https://example.com',
text: 'Example',
title: 'Visit Example'
);
$html = $link->toHtml();
expect($html)->toContain('<a href="https://example.com"')
->and($html)->toContain('title="Visit Example"')
->and($html)->toContain('>Example</a>');
});
it('renders external link with all security attributes', function () {
$link = HtmlLink::external('https://external.com', 'External');
$html = $link->toHtml();
expect($html)->toContain('target="_blank"')
->and($html)->toContain('rel="noopener noreferrer external"');
});
it('renders download link correctly', function () {
$link = HtmlLink::download('/file.pdf', 'Download', 'document.pdf');
$html = $link->toHtml();
expect($html)->toContain('download="document.pdf"');
});
it('escapes HTML in attributes', function () {
$link = new HtmlLink(
href: 'https://example.com',
text: '<script>alert("xss")</script>',
title: 'Test "quotes" & <tags>'
);
$html = $link->toHtml();
expect($html)->not->toContain('<script>')
->and($html)->toContain('&lt;script&gt;')
->and($html)->toContain('&quot;')
->and($html)->toContain('&amp;');
});
it('supports immutable with methods', function () {
$original = HtmlLink::create('https://example.com', 'Original');
$modified = $original
->withText('Modified')
->withTitle('New Title')
->withTarget(LinkTarget::BLANK)
->withRel(LinkRel::NOOPENER);
expect($original->text)->toBe('Original')
->and($original->title)->toBeNull()
->and($modified->text)->toBe('Modified')
->and($modified->title)->toBe('New Title')
->and($modified->target)->toBe(LinkTarget::BLANK)
->and($modified->rel)->toContain(LinkRel::NOOPENER);
});
it('can add CSS classes', function () {
$link = HtmlLink::create('https://example.com', 'Example')
->withClass('btn btn-primary');
$html = $link->toHtml();
expect($html)->toContain('class="btn btn-primary"');
});
it('can add data attributes', function () {
$link = HtmlLink::create('https://example.com', 'Example')
->withDataAttribute('toggle', 'modal')
->withDataAttribute('target', '#myModal');
$html = $link->toHtml();
expect($html)->toContain('data-toggle="modal"')
->and($html)->toContain('data-target="#myModal"');
});
it('renders disabled link with accessibility attributes', function () {
$link = HtmlLink::create('https://example.com', 'Disabled')
->withDisabled(true);
$html = $link->toHtml();
expect($html)->toContain('aria-disabled="true"')
->and($html)->toContain('tabindex="-1"');
});
it('can render as link element', function () {
$link = new HtmlLink(
href: '/styles.css',
rel: [LinkRel::STYLESHEET],
type: 'text/css'
);
$html = $link->toLinkElement();
expect($html)->toStartWith('<link ')
->and($html)->toContain('href="/styles.css"')
->and($html)->toContain('rel="stylesheet"')
->and($html)->toContain('type="text/css"')
->and($html)->toEndWith('>'); // HTML5 self-closing tags don't need />
});
it('detects external links', function () {
$internal = HtmlLink::create('/page', 'Internal');
$external1 = HtmlLink::create('https://example.com', 'External');
$external2 = HtmlLink::create('http://test.com', 'External');
expect($internal->isExternal())->toBeFalse()
->and($external1->isExternal())->toBeTrue()
->and($external2->isExternal())->toBeTrue();
});
it('throws exception for empty href', function () {
expect(fn() => new HtmlLink(href: ''))
->toThrow(InvalidArgumentException::class, 'Link href cannot be empty');
});
it('converts to string as HTML anchor', function () {
$link = HtmlLink::create('https://example.com', 'Example');
$string = (string) $link;
expect($string)->toContain('<a href="https://example.com"')
->and($string)->toContain('>Example</a>');
});
});
describe('LinkRel Enum', function () {
it('can detect external rels', function () {
expect(LinkRel::EXTERNAL->isExternal())->toBeTrue()
->and(LinkRel::NOFOLLOW->isExternal())->toBeTrue()
->and(LinkRel::STYLESHEET->isExternal())->toBeFalse();
});
it('can detect resource hints', function () {
expect(LinkRel::PRELOAD->isResourceHint())->toBeTrue()
->and(LinkRel::PREFETCH->isResourceHint())->toBeTrue()
->and(LinkRel::STYLESHEET->isResourceHint())->toBeFalse();
});
it('can detect security-related rels', function () {
expect(LinkRel::NOOPENER->isSecurityRelated())->toBeTrue()
->and(LinkRel::NOREFERRER->isSecurityRelated())->toBeTrue()
->and(LinkRel::STYLESHEET->isSecurityRelated())->toBeFalse();
});
it('can combine multiple rels', function () {
$combined = LinkRel::combine(
LinkRel::NOOPENER,
LinkRel::NOREFERRER,
LinkRel::EXTERNAL
);
expect($combined)->toBe('noopener noreferrer external');
});
it('can create from string', function () {
$rel = LinkRel::fromString('nofollow');
expect($rel)->toBe(LinkRel::NOFOLLOW);
});
it('throws exception for invalid rel string', function () {
expect(fn() => LinkRel::fromString('invalid'))
->toThrow(InvalidArgumentException::class, 'Invalid link rel');
});
});
describe('LinkTarget Enum', function () {
it('detects new context targets', function () {
expect(LinkTarget::BLANK->opensNewContext())->toBeTrue()
->and(LinkTarget::SELF->opensNewContext())->toBeFalse();
});
it('detects frame-escaping targets', function () {
expect(LinkTarget::PARENT->escapesFrame())->toBeTrue()
->and(LinkTarget::TOP->escapesFrame())->toBeTrue()
->and(LinkTarget::SELF->escapesFrame())->toBeFalse();
});
it('provides recommended rel for blank target', function () {
$recommended = LinkTarget::BLANK->getRecommendedRel();
expect($recommended)->toContain(LinkRel::NOOPENER)
->and($recommended)->toContain(LinkRel::NOREFERRER);
});
it('can create from string', function () {
$target = LinkTarget::fromString('_blank');
expect($target)->toBe(LinkTarget::BLANK);
});
it('throws exception for invalid target string', function () {
expect(fn() => LinkTarget::fromString('invalid'))
->toThrow(InvalidArgumentException::class, 'Invalid link target');
});
});

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\LinkCollection;
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Core\ValueObjects\AccessibleLink;
use App\Framework\Core\ValueObjects\LinkTarget;
use App\Framework\Core\ValueObjects\LinkRel;
describe('LinkCollection Value Object', function () {
it('can create with variadic constructor', function () {
$link1 = HtmlLink::create('/page1', 'Page 1');
$link2 = HtmlLink::create('/page2', 'Page 2');
$link3 = HtmlLink::create('/page3', 'Page 3');
$collection = new LinkCollection($link1, $link2, $link3);
expect($collection->count())->toBe(3)
->and($collection->first())->toBe($link1)
->and($collection->last())->toBe($link3);
});
it('can create from array', function () {
$links = [
HtmlLink::create('/home', 'Home'),
HtmlLink::create('/about', 'About'),
HtmlLink::create('/contact', 'Contact')
];
$collection = LinkCollection::fromArray($links);
expect($collection->count())->toBe(3);
});
it('can create from URLs and texts', function () {
$items = [
['url' => '/home', 'text' => 'Home'],
['url' => '/about', 'text' => 'About', 'current' => true],
['url' => '/contact', 'text' => 'Contact']
];
$collection = LinkCollection::fromUrlsAndTexts($items);
expect($collection->count())->toBe(3);
$aboutLink = $collection->get(1);
expect($aboutLink)->toBeInstanceOf(AccessibleLink::class)
->and($aboutLink->isCurrent())->toBeTrue();
});
it('implements Countable', function () {
$collection = new LinkCollection(
HtmlLink::create('/page1', 'Page 1'),
HtmlLink::create('/page2', 'Page 2')
);
expect(count($collection))->toBe(2);
});
it('implements IteratorAggregate', function () {
$link1 = HtmlLink::create('/page1', 'Page 1');
$link2 = HtmlLink::create('/page2', 'Page 2');
$collection = new LinkCollection($link1, $link2);
$links = [];
foreach ($collection as $link) {
$links[] = $link;
}
expect($links)->toHaveCount(2)
->and($links[0])->toBe($link1)
->and($links[1])->toBe($link2);
});
it('can get link at index', function () {
$link1 = HtmlLink::create('/page1', 'Page 1');
$link2 = HtmlLink::create('/page2', 'Page 2');
$collection = new LinkCollection($link1, $link2);
expect($collection->get(0))->toBe($link1)
->and($collection->get(1))->toBe($link2)
->and($collection->get(99))->toBeNull();
});
it('can check if empty', function () {
$emptyCollection = new LinkCollection();
$filledCollection = new LinkCollection(
HtmlLink::create('/page', 'Page')
);
expect($emptyCollection->isEmpty())->toBeTrue()
->and($filledCollection->isEmpty())->toBeFalse();
});
it('can filter links', function () {
$collection = new LinkCollection(
HtmlLink::create('/home', 'Home'),
HtmlLink::external('https://example.com', 'External'),
HtmlLink::create('/about', 'About')
);
$external = $collection->filter(fn($link) => $link->isExternal());
expect($external->count())->toBe(1)
->and($external->first()->text)->toBe('External');
});
it('can map links', function () {
$collection = new LinkCollection(
HtmlLink::create('/page1', 'Page 1'),
HtmlLink::create('/page2', 'Page 2')
);
$withClass = $collection->map(fn($link) => $link->withClass('btn'));
expect($withClass->count())->toBe(2)
->and($withClass->first()->cssClass)->toBe('btn');
});
it('can find link by condition', function () {
$collection = new LinkCollection(
HtmlLink::create('/home', 'Home'),
HtmlLink::create('/about', 'About Us'),
HtmlLink::create('/contact', 'Contact')
);
$found = $collection->find(fn($link) => $link->text === 'About Us');
expect($found)->not->toBeNull()
->and($found->getHref())->toBe('/about');
$notFound = $collection->find(fn($link) => $link->text === 'Blog');
expect($notFound)->toBeNull();
});
it('can add link immutably', function () {
$original = new LinkCollection(
HtmlLink::create('/page1', 'Page 1')
);
$newLink = HtmlLink::create('/page2', 'Page 2');
$modified = $original->add($newLink);
expect($original->count())->toBe(1)
->and($modified->count())->toBe(2)
->and($modified->last())->toBe($newLink);
});
it('can prepend link immutably', function () {
$original = new LinkCollection(
HtmlLink::create('/page2', 'Page 2')
);
$newLink = HtmlLink::create('/page1', 'Page 1');
$modified = $original->prepend($newLink);
expect($original->count())->toBe(1)
->and($modified->count())->toBe(2)
->and($modified->first())->toBe($newLink);
});
it('can merge collections immutably', function () {
$collection1 = new LinkCollection(
HtmlLink::create('/page1', 'Page 1'),
HtmlLink::create('/page2', 'Page 2')
);
$collection2 = new LinkCollection(
HtmlLink::create('/page3', 'Page 3'),
HtmlLink::create('/page4', 'Page 4')
);
$merged = $collection1->merge($collection2);
expect($collection1->count())->toBe(2)
->and($collection2->count())->toBe(2)
->and($merged->count())->toBe(4);
});
it('renders as navigation menu', function () {
$collection = new LinkCollection(
HtmlLink::create('/home', 'Home'),
HtmlLink::create('/about', 'About'),
AccessibleLink::currentPage('/contact', 'Contact')
);
$html = $collection->toNavigation(
ariaLabel: 'Main navigation',
cssClass: 'main-nav',
ulClass: 'nav-list'
);
expect($html)->toContain('<nav')
->and($html)->toContain('aria-label="Main navigation"')
->and($html)->toContain('class="main-nav"')
->and($html)->toContain('<ul')
->and($html)->toContain('class="nav-list"')
->and($html)->toContain('role="list"')
->and($html)->toContain('<li>')
->and($html)->toContain('href="/home"')
->and($html)->toContain('>Home</a>')
->and($html)->toContain('aria-current="page"')
->and($html)->toContain('>Contact</a>')
->and($html)->toContain('</nav>');
});
it('returns empty string for empty navigation', function () {
$collection = new LinkCollection();
expect($collection->toNavigation())->toBe('');
});
it('renders as breadcrumbs with schema.org markup', function () {
$collection = new LinkCollection(
HtmlLink::create('/', 'Home'),
HtmlLink::create('/products', 'Products'),
HtmlLink::create('/products/widgets', 'Widgets')
);
$html = $collection->toBreadcrumbs(
cssClass: 'breadcrumb',
olClass: 'breadcrumb-list'
);
expect($html)->toContain('<nav')
->and($html)->toContain('aria-label="Breadcrumb"')
->and($html)->toContain('class="breadcrumb"')
->and($html)->toContain('<ol')
->and($html)->toContain('itemscope')
->and($html)->toContain('itemtype="https://schema.org/BreadcrumbList"')
->and($html)->toContain('class="breadcrumb-list"')
->and($html)->toContain('itemprop="itemListElement"')
->and($html)->toContain('itemtype="https://schema.org/ListItem"')
->and($html)->toContain('itemprop="item"')
->and($html)->toContain('itemprop="name"')
->and($html)->toContain('itemprop="position"')
->and($html)->toContain('content="1"')
->and($html)->toContain('content="2"')
->and($html)->toContain('content="3"')
->and($html)->toContain('>Home</span></a>')
->and($html)->toContain('>Products</span></a>')
->and($html)->toContain('Widgets<meta'); // Last item without link but with meta tag
});
it('returns empty string for empty breadcrumbs', function () {
$collection = new LinkCollection();
expect($collection->toBreadcrumbs())->toBe('');
});
it('converts to string as navigation', function () {
$collection = new LinkCollection(
HtmlLink::create('/page1', 'Page 1')
);
$string = (string) $collection;
expect($string)->toContain('<nav')
->and($string)->toContain('href="/page1"');
});
it('validates link types in constructor', function () {
expect(fn() => new LinkCollection(
HtmlLink::create('/page', 'Page'),
'invalid' // Wrong type
))->toThrow(\TypeError::class);
});
it('can convert to array', function () {
$link1 = HtmlLink::create('/page1', 'Page 1');
$link2 = HtmlLink::create('/page2', 'Page 2');
$collection = new LinkCollection($link1, $link2);
$array = $collection->toArray();
expect($array)->toBeArray()
->and($array)->toHaveCount(2)
->and($array[0])->toBe($link1)
->and($array[1])->toBe($link2);
});
it('handles mixed HtmlLink and AccessibleLink', function () {
$collection = new LinkCollection(
HtmlLink::create('/page1', 'Page 1'),
AccessibleLink::create('/page2', 'Page 2', 'Accessible page 2'),
HtmlLink::external('https://example.com', 'External'),
AccessibleLink::skipLink('main-content', 'Skip to content')
);
expect($collection->count())->toBe(4);
$html = $collection->toNavigation();
expect($html)->toContain('href="/page1"')
->and($html)->toContain('href="/page2"')
->and($html)->toContain('aria-label="Accessible page 2"')
->and($html)->toContain('https://example.com')
->and($html)->toContain('href="#main-content"')
->and($html)->toContain('aria-label="Skip to content"');
});
});

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Core\ValueObjects\QualifiedMethodName;
// Test class for method existence checks
class MethodNameTestClass
{
public function publicMethod(): string
{
return 'public';
}
private function privateMethod(): string
{
return 'private';
}
public static function staticMethod(): string
{
return 'static';
}
public function __invoke(): string
{
return 'invoked';
}
}
describe('MethodName', function () {
it('creates method name from string', function () {
$method = MethodName::create('getUserById');
expect($method->toString())->toBe('getUserById');
expect($method->isMagicMethod())->toBeFalse();
});
it('validates method name format', function () {
expect(fn () => MethodName::create(''))
->toThrow(InvalidArgumentException::class, 'Method name cannot be empty');
expect(fn () => MethodName::create('123invalid'))
->toThrow(InvalidArgumentException::class, 'Invalid method name');
expect(fn () => MethodName::create('invalid-name'))
->toThrow(InvalidArgumentException::class, 'Invalid method name');
});
it('creates magic method instances', function () {
$construct = MethodName::construct();
$invoke = MethodName::invoke();
$toString = MethodName::toStringMagic();
$destruct = MethodName::destruct();
expect($construct->isConstructor())->toBeTrue();
expect($construct->isMagicMethod())->toBeTrue();
expect($invoke->isInvokable())->toBeTrue();
expect($invoke->isMagicMethod())->toBeTrue();
expect($toString->isToString())->toBeTrue();
expect($toString->isMagicMethod())->toBeTrue();
expect($destruct->isDestructor())->toBeTrue();
expect($destruct->isMagicMethod())->toBeTrue();
});
it('checks method existence in class', function () {
$className = ClassName::create(MethodNameTestClass::class);
$publicMethod = MethodName::create('publicMethod');
$privateMethod = MethodName::create('privateMethod');
$nonExistent = MethodName::create('nonExistentMethod');
expect($publicMethod->existsIn($className))->toBeTrue();
expect($privateMethod->existsIn($className))->toBeTrue();
expect($nonExistent->existsIn($className))->toBeFalse();
});
it('checks method visibility', function () {
$className = ClassName::create(MethodNameTestClass::class);
$publicMethod = MethodName::create('publicMethod');
$privateMethod = MethodName::create('privateMethod');
expect($publicMethod->isPublicIn($className))->toBeTrue();
expect($privateMethod->isPublicIn($className))->toBeFalse();
});
it('checks if method is static', function () {
$className = ClassName::create(MethodNameTestClass::class);
$staticMethod = MethodName::create('staticMethod');
$publicMethod = MethodName::create('publicMethod');
expect($staticMethod->isStaticIn($className))->toBeTrue();
expect($publicMethod->isStaticIn($className))->toBeFalse();
});
it('gets reflection for method', function () {
$className = ClassName::create(MethodNameTestClass::class);
$method = MethodName::create('publicMethod');
$reflection = $method->getReflection($className);
expect($reflection)->toBeInstanceOf(ReflectionMethod::class);
expect($reflection->getName())->toBe('publicMethod');
expect($reflection->isPublic())->toBeTrue();
});
it('returns null reflection for non-existent method', function () {
$className = ClassName::create(MethodNameTestClass::class);
$method = MethodName::create('nonExistentMethod');
expect($method->getReflection($className))->toBeNull();
});
it('matches method name patterns', function () {
$method1 = MethodName::create('getUserById');
$method2 = MethodName::create('getUserByEmail');
$method3 = MethodName::create('createUser');
expect($method1->matches('getUser*'))->toBeTrue();
expect($method2->matches('getUser*'))->toBeTrue();
expect($method3->matches('getUser*'))->toBeFalse();
expect($method1->matches('*By*'))->toBeTrue();
});
it('compares method names for equality', function () {
$method1 = MethodName::create('testMethod');
$method2 = MethodName::create('testMethod');
$method3 = MethodName::create('otherMethod');
expect($method1->equals($method2))->toBeTrue();
expect($method1->equals($method3))->toBeFalse();
});
it('converts to string', function () {
$method = MethodName::create('testMethod');
expect($method->toString())->toBe('testMethod');
expect((string) $method)->toBe('testMethod');
});
});
describe('QualifiedMethodName', function () {
it('parses qualified method string', function () {
$qualified = MethodName::fromQualified('MethodNameTestClass::publicMethod');
expect($qualified)->toBeInstanceOf(QualifiedMethodName::class);
expect($qualified->className->getShortName())->toBe('MethodNameTestClass');
expect($qualified->methodName->toString())->toBe('publicMethod');
});
it('throws on invalid qualified format', function () {
expect(fn () => MethodName::fromQualified('InvalidFormat'))
->toThrow(InvalidArgumentException::class, "Expected format 'ClassName::methodName'");
});
it('creates qualified method from ClassName', function () {
$className = ClassName::create(MethodNameTestClass::class);
$qualified = MethodName::withClass($className, 'publicMethod');
expect($qualified->className->equals($className))->toBeTrue();
expect($qualified->methodName->toString())->toBe('publicMethod');
});
it('checks qualified method existence', function () {
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$nonExistent = QualifiedMethodName::fromString('MethodNameTestClass::nonExistent');
expect($qualified->exists())->toBeTrue();
expect($nonExistent->exists())->toBeFalse();
});
it('checks qualified method visibility', function () {
$publicMethod = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$privateMethod = QualifiedMethodName::fromString('MethodNameTestClass::privateMethod');
expect($publicMethod->isPublic())->toBeTrue();
expect($privateMethod->isPublic())->toBeFalse();
});
it('checks if qualified method is static', function () {
$staticMethod = QualifiedMethodName::fromString('MethodNameTestClass::staticMethod');
$publicMethod = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
expect($staticMethod->isStatic())->toBeTrue();
expect($publicMethod->isStatic())->toBeFalse();
});
it('converts to string representations', function () {
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
expect($qualified->toString())->toBe('MethodNameTestClass::publicMethod');
expect((string) $qualified)->toBe('MethodNameTestClass::publicMethod');
});
it('invokes non-static method with instance', function () {
$instance = new MethodNameTestClass();
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$result = $qualified->invoke($instance);
expect($result)->toBe('public');
});
it('invokes static method without instance', function () {
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::staticMethod');
$result = $qualified->invoke(null);
expect($result)->toBe('static');
});
it('invokes magic __invoke method', function () {
$instance = new MethodNameTestClass();
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::__invoke');
$result = $qualified->invoke($instance);
expect($result)->toBe('invoked');
});
it('throws when invoking non-static method without instance', function () {
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
expect(fn () => $qualified->invoke(null))
->toThrow(BadMethodCallException::class, 'Cannot invoke non-static method');
});
it('throws when invoking non-existent method', function () {
$instance = new MethodNameTestClass();
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::nonExistent');
expect(fn () => $qualified->invoke($instance))
->toThrow(BadMethodCallException::class, 'does not exist');
});
it('compares qualified method names for equality', function () {
$qualified1 = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$qualified2 = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$qualified3 = QualifiedMethodName::fromString('MethodNameTestClass::privateMethod');
expect($qualified1->equals($qualified2))->toBeTrue();
expect($qualified1->equals($qualified3))->toBeFalse();
});
it('delegates magic method checks', function () {
$construct = QualifiedMethodName::fromString('MethodNameTestClass::__construct');
$invoke = QualifiedMethodName::fromString('MethodNameTestClass::__invoke');
expect($construct->isMagicMethod())->toBeTrue();
expect($construct->isConstructor())->toBeTrue();
expect($invoke->isMagicMethod())->toBeTrue();
expect($invoke->isInvokable())->toBeTrue();
});
it('gets reflection for qualified method', function () {
$qualified = QualifiedMethodName::fromString('MethodNameTestClass::publicMethod');
$reflection = $qualified->getReflection();
expect($reflection)->toBeInstanceOf(ReflectionMethod::class);
expect($reflection->getName())->toBe('publicMethod');
});
});

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\StructuredData\WebPage;
use App\Framework\Core\ValueObjects\StructuredData\WebPageType;
use App\Framework\Core\ValueObjects\StructuredData\Article;
use App\Framework\Core\ValueObjects\StructuredData\ArticleType;
use App\Framework\Core\ValueObjects\StructuredData\Organization;
use App\Framework\Core\ValueObjects\StructuredData\OrganizationType;
use App\Framework\Core\ValueObjects\StructuredData\Person;
use App\Framework\Core\ValueObjects\StructuredData\StructuredDataCollection;
use App\Framework\Core\ValueObjects\LinkCollection;
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Http\Url\UrlFactory;
describe('WebPage Structured Data', function () {
it('creates basic webpage schema', function () {
$webpage = new WebPage(
url: UrlFactory::parse('https://example.com/page'),
name: 'Example Page'
);
$jsonLd = $webpage->toJsonLd();
expect($jsonLd)->toHaveKey('@context', 'https://schema.org')
->and($jsonLd)->toHaveKey('@type', 'WebPage')
->and($jsonLd)->toHaveKey('url', 'https://example.com/page')
->and($jsonLd)->toHaveKey('name', 'Example Page');
});
it('includes optional webpage properties', function () {
$webpage = new WebPage(
url: UrlFactory::parse('https://example.com/page'),
name: 'Example Page',
type: WebPageType::WEB_PAGE,
description: 'This is an example page',
datePublished: new \DateTimeImmutable('2024-01-01'),
dateModified: new \DateTimeImmutable('2024-01-15'),
inLanguage: 'en',
keywords: ['example', 'test', 'page']
);
$jsonLd = $webpage->toJsonLd();
expect($jsonLd)->toHaveKey('description', 'This is an example page')
->and($jsonLd)->toHaveKey('datePublished')
->and($jsonLd)->toHaveKey('dateModified')
->and($jsonLd)->toHaveKey('inLanguage', 'en')
->and($jsonLd)->toHaveKey('keywords', 'example, test, page');
});
it('includes breadcrumb navigation', function () {
$breadcrumb = new LinkCollection(
HtmlLink::create('/', 'Home'),
HtmlLink::create('/products', 'Products'),
HtmlLink::create('/products/widgets', 'Widgets')
);
$webpage = new WebPage(
url: UrlFactory::parse('https://example.com/products/widgets'),
name: 'Widgets',
breadcrumb: $breadcrumb
);
$jsonLd = $webpage->toJsonLd();
expect($jsonLd)->toHaveKey('breadcrumb')
->and($jsonLd['breadcrumb'])->toHaveKey('@type', 'BreadcrumbList')
->and($jsonLd['breadcrumb'])->toHaveKey('itemListElement')
->and($jsonLd['breadcrumb']['itemListElement'])->toHaveCount(3)
->and($jsonLd['breadcrumb']['itemListElement'][0])->toHaveKey('position', 1)
->and($jsonLd['breadcrumb']['itemListElement'][0])->toHaveKey('name', 'Home')
->and($jsonLd['breadcrumb']['itemListElement'][2])->toHaveKey('position', 3);
});
it('renders as JSON-LD script tag', function () {
$webpage = new WebPage(
url: UrlFactory::parse('https://example.com/page'),
name: 'Example Page'
);
$script = $webpage->toScript();
expect($script)->toStartWith('<script type="application/ld+json">')
->and($script)->toEndWith('</script>')
->and($script)->toContain('"@context":"https://schema.org"')
->and($script)->toContain('"@type":"WebPage"');
});
it('supports immutable with methods', function () {
$original = new WebPage(
url: UrlFactory::parse('https://example.com/page'),
name: 'Example Page'
);
$modified = $original->withDescription('New description')
->withKeywords('seo', 'test');
expect($original->description)->toBeNull()
->and($original->keywords)->toBeEmpty()
->and($modified->description)->toBe('New description')
->and($modified->keywords)->toHaveCount(2);
});
});
describe('Article Structured Data', function () {
it('creates basic article schema', function () {
$article = new Article(
headline: 'Test Article',
url: UrlFactory::parse('https://example.com/article'),
datePublished: new \DateTimeImmutable('2024-01-01')
);
$jsonLd = $article->toJsonLd();
expect($jsonLd)->toHaveKey('@context', 'https://schema.org')
->and($jsonLd)->toHaveKey('@type', 'Article')
->and($jsonLd)->toHaveKey('headline', 'Test Article')
->and($jsonLd)->toHaveKey('url', 'https://example.com/article')
->and($jsonLd)->toHaveKey('datePublished');
});
it('includes author and publisher', function () {
$author = new Person(name: 'John Doe');
$publisher = new Organization(name: 'Example Corp');
$article = new Article(
headline: 'Test Article',
url: UrlFactory::parse('https://example.com/article'),
datePublished: new \DateTimeImmutable('2024-01-01'),
author: $author,
publisher: $publisher
);
$jsonLd = $article->toJsonLd();
expect($jsonLd)->toHaveKey('author')
->and($jsonLd['author'])->toHaveKey('@type', 'Person')
->and($jsonLd['author'])->toHaveKey('name', 'John Doe')
->and($jsonLd)->toHaveKey('publisher')
->and($jsonLd['publisher'])->toHaveKey('@type', 'Organization');
});
it('validates headline length', function () {
$longHeadline = str_repeat('A', 111);
expect(fn() => new Article(
headline: $longHeadline,
url: UrlFactory::parse('https://example.com/article'),
datePublished: new \DateTimeImmutable()
))->toThrow(\InvalidArgumentException::class, 'headline should not exceed 110 characters');
});
it('supports immutable with methods', function () {
$original = new Article(
headline: 'Test',
url: UrlFactory::parse('https://example.com/article'),
datePublished: new \DateTimeImmutable()
);
$author = new Person(name: 'Jane Doe');
$modified = $original->withAuthor($author);
expect($original->author)->toBeNull()
->and($modified->author)->toBe($author);
});
});
describe('Organization Structured Data', function () {
it('creates basic organization schema', function () {
$org = new Organization(name: 'Example Corp');
$jsonLd = $org->toJsonLd();
expect($jsonLd)->toHaveKey('@context', 'https://schema.org')
->and($jsonLd)->toHaveKey('@type', 'Organization')
->and($jsonLd)->toHaveKey('name', 'Example Corp');
});
it('includes optional organization properties', function () {
$org = new Organization(
name: 'Example Corp',
type: OrganizationType::CORPORATION,
url: UrlFactory::parse('https://example.com'),
logo: UrlFactory::parse('https://example.com/logo.png'),
description: 'A great company'
);
$jsonLd = $org->toJsonLd();
expect($jsonLd)->toHaveKey('url', 'https://example.com/')
->and($jsonLd)->toHaveKey('logo', 'https://example.com/logo.png')
->and($jsonLd)->toHaveKey('description', 'A great company');
});
it('includes social media profiles', function () {
$org = new Organization(
name: 'Example Corp',
sameAs: [
'https://twitter.com/example',
'https://facebook.com/example',
'https://linkedin.com/company/example'
]
);
$jsonLd = $org->toJsonLd();
expect($jsonLd)->toHaveKey('sameAs')
->and($jsonLd['sameAs'])->toHaveCount(3)
->and($jsonLd['sameAs'])->toContain('https://twitter.com/example');
});
it('supports adding social profiles immutably', function () {
$original = new Organization(name: 'Example Corp');
$modified = $original->addSocialProfile('https://twitter.com/example');
expect($original->sameAs)->toBeEmpty()
->and($modified->sameAs)->toHaveCount(1);
});
});
describe('Person Structured Data', function () {
it('creates basic person schema', function () {
$person = new Person(name: 'John Doe');
$jsonLd = $person->toJsonLd();
expect($jsonLd)->toHaveKey('@context', 'https://schema.org')
->and($jsonLd)->toHaveKey('@type', 'Person')
->and($jsonLd)->toHaveKey('name', 'John Doe');
});
it('includes optional person properties', function () {
$person = new Person(
name: 'John Doe',
url: UrlFactory::parse('https://johndoe.com'),
image: UrlFactory::parse('https://johndoe.com/photo.jpg'),
jobTitle: 'Software Engineer',
description: 'A skilled developer'
);
$jsonLd = $person->toJsonLd();
expect($jsonLd)->toHaveKey('url', 'https://johndoe.com/')
->and($jsonLd)->toHaveKey('image', 'https://johndoe.com/photo.jpg')
->and($jsonLd)->toHaveKey('jobTitle', 'Software Engineer')
->and($jsonLd)->toHaveKey('description', 'A skilled developer');
});
it('includes employer organization', function () {
$employer = new Organization(name: 'Example Corp');
$person = new Person(
name: 'John Doe',
worksFor: $employer
);
$jsonLd = $person->toJsonLd();
expect($jsonLd)->toHaveKey('worksFor')
->and($jsonLd['worksFor'])->toHaveKey('@type', 'Organization')
->and($jsonLd['worksFor'])->toHaveKey('name', 'Example Corp');
});
it('supports social profiles', function () {
$person = new Person(
name: 'John Doe',
sameAs: [
'https://twitter.com/johndoe',
'https://github.com/johndoe'
]
);
$jsonLd = $person->toJsonLd();
expect($jsonLd)->toHaveKey('sameAs')
->and($jsonLd['sameAs'])->toHaveCount(2);
});
});
describe('StructuredDataCollection', function () {
it('creates with variadic constructor', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$org = new Organization(name: 'Example Corp');
$collection = new StructuredDataCollection($webpage, $org);
expect($collection->count())->toBe(2)
->and($collection->isEmpty())->toBeFalse();
});
it('implements Countable and IteratorAggregate', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$collection = new StructuredDataCollection($webpage);
expect(count($collection))->toBe(1);
$items = [];
foreach ($collection as $item) {
$items[] = $item;
}
expect($items)->toHaveCount(1);
});
it('renders as separate JSON-LD script tags', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$org = new Organization(name: 'Example Corp');
$collection = new StructuredDataCollection($webpage, $org);
$scripts = $collection->toScripts();
expect($scripts)->toContain('<script type="application/ld+json">')
->and($scripts)->toContain('"@type":"WebPage"')
->and($scripts)->toContain('"@type":"Organization"')
->and(substr_count($scripts, '<script'))->toBe(2);
});
it('renders as single JSON-LD script with array', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$org = new Organization(name: 'Example Corp');
$collection = new StructuredDataCollection($webpage, $org);
$script = $collection->toSingleScript();
expect($script)->toContain('<script type="application/ld+json">')
->and($script)->toContain('"@type":"WebPage"')
->and($script)->toContain('"@type":"Organization"')
->and(substr_count($script, '<script'))->toBe(1);
});
it('returns empty string for empty collection', function () {
$collection = new StructuredDataCollection();
expect($collection->toScripts())->toBe('')
->and($collection->toSingleScript())->toBe('');
});
it('supports adding items immutably', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$org = new Organization(name: 'Example Corp');
$original = new StructuredDataCollection($webpage);
$modified = $original->add($org);
expect($original->count())->toBe(1)
->and($modified->count())->toBe(2);
});
it('supports merging collections immutably', function () {
$collection1 = new StructuredDataCollection(
new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home')
);
$collection2 = new StructuredDataCollection(
new Organization(name: 'Example Corp')
);
$merged = $collection1->merge($collection2);
expect($collection1->count())->toBe(1)
->and($collection2->count())->toBe(1)
->and($merged->count())->toBe(2);
});
it('converts to string as separate scripts', function () {
$webpage = new WebPage(url: UrlFactory::parse('https://example.com'), name: 'Home');
$collection = new StructuredDataCollection($webpage);
$string = (string) $collection;
expect($string)->toContain('<script type="application/ld+json">');
});
});