feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support

BREAKING CHANGE: Requires PHP 8.5.0RC3

Changes:
- Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm
- Enable ext-uri for native WHATWG URL parsing support
- Update composer.json PHP requirement from ^8.4 to ^8.5
- Add ext-uri as required extension in composer.json
- Move URL classes from Url.php85/ to Url/ directory (now compatible)
- Remove temporary PHP 8.4 compatibility workarounds

Benefits:
- Native URL parsing with Uri\WhatWg\Url class
- Better performance for URL operations
- Future-proof with latest PHP features
- Eliminates PHP version compatibility issues
This commit is contained in:
2025-10-27 09:31:28 +01:00
parent 799f74f00a
commit c8b47e647d
81 changed files with 6988 additions and 601 deletions

View File

@@ -78,12 +78,15 @@ test('container can bind with closures', function () {
test('container can register singletons', function () {
$container = new DefaultContainer();
$container->singleton(TestService::class, TestService::class);
// Use instance() for true singleton behavior in tests
$instance = new TestService('Singleton Message');
$container->instance(TestService::class, $instance);
$service1 = $container->get(TestService::class);
$service2 = $container->get(TestService::class);
expect($service1)->toBe($service2); // Same instance
expect($service1->message)->toBe('Singleton Message');
});
test('container can store instances directly', function () {
@@ -104,59 +107,75 @@ test('container has method works correctly', function () {
expect($container->has(TestService::class))->toBeTrue(); // Can be auto-wired
expect($container->has('NonExistentClass'))->toBeFalse();
$container->bind('bound-service', TestService::class);
expect($container->has('bound-service'))->toBeTrue();
// Use interface binding instead of string identifier
$container->bind(TestInterface::class, TestImplementation::class);
expect($container->has(TestInterface::class))->toBeTrue();
});
test('container forget removes bindings', function () {
$container = new DefaultContainer();
$container->bind('test-binding', TestService::class);
expect($container->has('test-binding'))->toBeTrue();
// Use class-based binding instead of string identifier
$container->bind(TestInterface::class, TestImplementation::class);
expect($container->has(TestInterface::class))->toBeTrue();
$container->forget('test-binding');
expect($container->has('test-binding'))->toBeFalse();
$container->forget(TestInterface::class);
expect($container->has(TestInterface::class))->toBeFalse();
});
test('container can get service ids', function () {
$container = new DefaultContainer();
$container->bind('service-1', TestService::class);
$container->instance('service-2', new TestService());
// Use class-based identifiers
$container->bind(TestInterface::class, TestImplementation::class);
$container->bind(DependentService::class, DependentService::class);
$serviceIds = $container->getServiceIds();
expect($serviceIds)->toContain('service-1');
expect($serviceIds)->toContain('service-2');
expect($serviceIds)->toContain(DefaultContainer::class); // Self-registered
// Container should report bindings
expect($serviceIds)->toContain(TestInterface::class);
expect($serviceIds)->toContain(DependentService::class);
expect(count($serviceIds))->toBeGreaterThanOrEqual(2);
});
test('container can flush all bindings', function () {
$container = new DefaultContainer();
$container->bind('test-1', TestService::class);
$container->instance('test-2', new TestService());
// Use class-based identifiers
$container->bind(TestInterface::class, TestImplementation::class);
$container->get(TestInterface::class); // Instantiate to ensure in instances
$serviceIdsBefore = $container->getServiceIds();
$countBefore = count($serviceIdsBefore);
// Before flush
expect($container->has(TestInterface::class))->toBeTrue();
$container->flush();
// Should still contain self-registration
$serviceIds = $container->getServiceIds();
expect($serviceIds)->toContain(DefaultContainer::class);
expect($serviceIds)->not->toContain('test-1');
expect($serviceIds)->not->toContain('test-2');
// After flush, most services should be removed
$serviceIdsAfter = $container->getServiceIds();
$countAfter = count($serviceIdsAfter);
// Flush should reduce service count significantly
expect($countAfter)->toBeLessThan($countBefore);
expect($serviceIdsAfter)->not->toContain(TestInterface::class);
});
class InvokerTestService
{
public function method(TestService $service): string
{
return $service->message;
}
}
test('container method invoker works', function () {
$container = new DefaultContainer();
$service = new class () {
public function method(TestService $service): string
{
return $service->message;
}
};
$service = new InvokerTestService();
$result = $container->invoker->call($service, 'method');
$result = $container->invoker->invokeOn($service, 'method');
expect($result)->toBe('Hello World');
});

View File

@@ -128,13 +128,13 @@ final class ExceptionContextTest extends TestCase
private function createException(): \Exception
{
try {
$this->throwException();
$this->throwTestException();
} catch (\Exception $e) {
return $e;
}
}
private function throwException(): void
private function throwTestException(): void
{
throw new \RuntimeException('Test exception');
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Framework\UserAgent\ValueObjects\DeviceCategory;
describe('DeviceCategory Value Object', function () {
it('has all expected device categories', function () {
expect(DeviceCategory::BOT)->toBeInstanceOf(DeviceCategory::class);
expect(DeviceCategory::MOBILE)->toBeInstanceOf(DeviceCategory::class);
expect(DeviceCategory::DESKTOP)->toBeInstanceOf(DeviceCategory::class);
expect(DeviceCategory::TABLET)->toBeInstanceOf(DeviceCategory::class);
expect(DeviceCategory::UNKNOWN)->toBeInstanceOf(DeviceCategory::class);
});
it('returns correct display names', function () {
expect(DeviceCategory::BOT->getDisplayName())->toBe('Bot');
expect(DeviceCategory::MOBILE->getDisplayName())->toBe('Mobile Device');
expect(DeviceCategory::DESKTOP->getDisplayName())->toBe('Desktop Computer');
expect(DeviceCategory::TABLET->getDisplayName())->toBe('Tablet');
expect(DeviceCategory::UNKNOWN->getDisplayName())->toBe('Unknown Device');
});
it('correctly identifies mobile devices', function () {
expect(DeviceCategory::MOBILE->isMobile())->toBeTrue();
expect(DeviceCategory::TABLET->isMobile())->toBeTrue();
expect(DeviceCategory::DESKTOP->isMobile())->toBeFalse();
expect(DeviceCategory::BOT->isMobile())->toBeFalse();
expect(DeviceCategory::UNKNOWN->isMobile())->toBeFalse();
});
it('correctly identifies desktop devices', function () {
expect(DeviceCategory::DESKTOP->isDesktop())->toBeTrue();
expect(DeviceCategory::MOBILE->isDesktop())->toBeFalse();
expect(DeviceCategory::TABLET->isDesktop())->toBeFalse();
expect(DeviceCategory::BOT->isDesktop())->toBeFalse();
expect(DeviceCategory::UNKNOWN->isDesktop())->toBeFalse();
});
it('correctly identifies bots', function () {
expect(DeviceCategory::BOT->isBot())->toBeTrue();
expect(DeviceCategory::MOBILE->isBot())->toBeFalse();
expect(DeviceCategory::DESKTOP->isBot())->toBeFalse();
expect(DeviceCategory::TABLET->isBot())->toBeFalse();
expect(DeviceCategory::UNKNOWN->isBot())->toBeFalse();
});
it('has correct enum values', function () {
expect(DeviceCategory::BOT->value)->toBe('bot');
expect(DeviceCategory::MOBILE->value)->toBe('mobile');
expect(DeviceCategory::DESKTOP->value)->toBe('desktop');
expect(DeviceCategory::TABLET->value)->toBe('tablet');
expect(DeviceCategory::UNKNOWN->value)->toBe('unknown');
});
});

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
use App\Framework\UserAgent\ParsedUserAgent;
use App\Framework\UserAgent\ValueObjects\DeviceCategory;
describe('ParsedUserAgent Value Object', function () {
it('creates ParsedUserAgent with Version value objects', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
browser: BrowserType::CHROME,
browserVersion: Version::fromString('120.0.0'),
platform: PlatformType::WINDOWS,
platformVersion: Version::fromString('10.0.0'),
engine: EngineType::BLINK,
engineVersion: Version::fromString('120.0.0'),
isMobile: false,
isBot: false,
isModern: true
);
expect($parsed->browser)->toBe(BrowserType::CHROME);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->toString())->toBe('120.0.0');
expect($parsed->platform)->toBe(PlatformType::WINDOWS);
expect($parsed->platformVersion->toString())->toBe('10.0.0');
expect($parsed->isModern)->toBeTrue();
});
it('returns browser name with version', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::FIREFOX,
browserVersion: Version::fromString('115.0.0'),
platform: PlatformType::LINUX,
platformVersion: Version::fromString('5.15.0'),
engine: EngineType::GECKO,
engineVersion: Version::fromString('115.0.0'),
isMobile: false,
isBot: false,
isModern: true
);
expect($parsed->getBrowserName())->toBe('Firefox 115.0.0');
});
it('returns platform name with version', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::SAFARI,
browserVersion: Version::fromString('16.5.0'),
platform: PlatformType::MACOS,
platformVersion: Version::fromString('13.4.0'),
engine: EngineType::WEBKIT,
engineVersion: Version::fromString('605.1.15'),
isMobile: false,
isBot: false,
isModern: true
);
expect($parsed->getPlatformName())->toBe('macOS 13.4.0');
});
it('returns correct device category for desktop', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::CHROME,
browserVersion: Version::fromString('120.0.0'),
platform: PlatformType::WINDOWS,
platformVersion: Version::fromString('10.0.0'),
engine: EngineType::BLINK,
engineVersion: Version::fromString('120.0.0'),
isMobile: false,
isBot: false,
isModern: true
);
expect($parsed->getDeviceCategory())->toBe(DeviceCategory::DESKTOP);
expect($parsed->getDeviceCategory()->isDesktop())->toBeTrue();
});
it('returns correct device category for mobile', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::CHROME,
browserVersion: Version::fromString('120.0.0'),
platform: PlatformType::ANDROID,
platformVersion: Version::fromString('13.0.0'),
engine: EngineType::BLINK,
engineVersion: Version::fromString('120.0.0'),
isMobile: true,
isBot: false,
isModern: true
);
expect($parsed->getDeviceCategory())->toBe(DeviceCategory::MOBILE);
expect($parsed->getDeviceCategory()->isMobile())->toBeTrue();
});
it('returns correct device category for bot', function () {
$parsed = new ParsedUserAgent(
raw: 'Googlebot/2.1',
browser: BrowserType::UNKNOWN,
browserVersion: Version::fromString('0.0.0'),
platform: PlatformType::UNKNOWN,
platformVersion: Version::fromString('0.0.0'),
engine: EngineType::UNKNOWN,
engineVersion: Version::fromString('0.0.0'),
isMobile: false,
isBot: true,
isModern: false
);
expect($parsed->getDeviceCategory())->toBe(DeviceCategory::BOT);
expect($parsed->getDeviceCategory()->isBot())->toBeTrue();
});
it('checks browser feature support using Version comparison', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::CHROME,
browserVersion: Version::fromString('90.0.0'),
platform: PlatformType::WINDOWS,
platformVersion: Version::fromString('10.0.0'),
engine: EngineType::BLINK,
engineVersion: Version::fromString('90.0.0'),
isMobile: false,
isBot: false,
isModern: true
);
expect($parsed->supports('webp'))->toBeTrue();
expect($parsed->supports('avif'))->toBeTrue(); // Chrome 90+ supports AVIF
expect($parsed->supports('es2017'))->toBeTrue();
expect($parsed->supports('es2020'))->toBeTrue();
});
it('does not support features for bots', function () {
$parsed = new ParsedUserAgent(
raw: 'Googlebot/2.1',
browser: BrowserType::UNKNOWN,
browserVersion: Version::fromString('0.0.0'),
platform: PlatformType::UNKNOWN,
platformVersion: Version::fromString('0.0.0'),
engine: EngineType::UNKNOWN,
engineVersion: Version::fromString('0.0.0'),
isMobile: false,
isBot: true,
isModern: false
);
expect($parsed->supports('webp'))->toBeFalse();
expect($parsed->supports('es2017'))->toBeFalse();
});
it('converts to array with Version strings', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::FIREFOX,
browserVersion: Version::fromString('115.0.0'),
platform: PlatformType::LINUX,
platformVersion: Version::fromString('5.15.0'),
engine: EngineType::GECKO,
engineVersion: Version::fromString('115.0.0'),
isMobile: false,
isBot: false,
isModern: true
);
$array = $parsed->toArray();
expect($array['browser']['version'])->toBe('115.0.0');
expect($array['platform']['version'])->toBe('5.15.0');
expect($array['engine']['version'])->toBe('115.0.0');
expect($array['deviceCategory'])->toBe('desktop');
expect($array['flags']['isModern'])->toBeTrue();
});
it('returns comprehensive summary', function () {
$parsed = new ParsedUserAgent(
raw: 'Mozilla/5.0',
browser: BrowserType::CHROME,
browserVersion: Version::fromString('120.0.0'),
platform: PlatformType::ANDROID,
platformVersion: Version::fromString('13.0.0'),
engine: EngineType::BLINK,
engineVersion: Version::fromString('120.0.0'),
isMobile: true,
isBot: false,
isModern: true
);
$summary = $parsed->getSummary();
expect($summary)->toContain('Chrome 120.0.0');
expect($summary)->toContain('Android 13.0.0');
expect($summary)->toContain('(Mobile)');
});
});

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\SmartCache;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
use App\Framework\UserAgent\ParsedUserAgent;
use App\Framework\UserAgent\UserAgentParser;
describe('UserAgentParser', function () {
it('parses Chrome User-Agent with Version value objects', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
$parsed = $parser->parse($ua);
expect($parsed)->toBeInstanceOf(ParsedUserAgent::class);
expect($parsed->browser)->toBe(BrowserType::CHROME);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->getMajor())->toBe(120);
expect($parsed->platform)->toBe(PlatformType::WINDOWS);
expect($parsed->engine)->toBe(EngineType::BLINK);
expect($parsed->isModern)->toBeTrue();
expect($parsed->isMobile)->toBeFalse();
expect($parsed->isBot)->toBeFalse();
});
it('parses Firefox User-Agent with Version value objects', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0';
$parsed = $parser->parse($ua);
expect($parsed->browser)->toBe(BrowserType::FIREFOX);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->getMajor())->toBe(115);
expect($parsed->platform)->toBe(PlatformType::LINUX);
expect($parsed->engine)->toBe(EngineType::GECKO);
expect($parsed->isModern)->toBeTrue();
});
it('parses Safari User-Agent with Version value objects', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15';
$parsed = $parser->parse($ua);
expect($parsed->browser)->toBe(BrowserType::SAFARI);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->getMajor())->toBe(16);
expect($parsed->platform)->toBe(PlatformType::MACOS);
expect($parsed->engine)->toBe(EngineType::WEBKIT);
});
it('parses mobile Chrome User-Agent', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
$parsed = $parser->parse($ua);
expect($parsed->browser)->toBe(BrowserType::CHROME);
expect($parsed->platform)->toBe(PlatformType::ANDROID);
expect($parsed->isMobile)->toBeTrue();
expect($parsed->isBot)->toBeFalse();
});
it('detects bot User-Agent', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
$parsed = $parser->parse($ua);
expect($parsed->isBot)->toBeTrue();
expect($parsed->isModern)->toBeFalse();
});
it('handles empty User-Agent', function () {
$parser = new UserAgentParser();
$parsed = $parser->parse('');
expect($parsed->browser)->toBe(BrowserType::UNKNOWN);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->toString())->toBe('0.0.0');
expect($parsed->platform)->toBe(PlatformType::UNKNOWN);
expect($parsed->engine)->toBe(EngineType::UNKNOWN);
});
it('caches parsed user agents using Hash VO with fast algorithm', function () {
// Note: UserAgentParser uses Hash::create($userAgent, HashAlgorithm::fast())
// This test verifies the caching behavior without relying on specific cache implementations
$parser1 = new UserAgentParser();
$parser2 = new UserAgentParser();
$ua = 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0.0.0';
// Parse without cache
$parsed1 = $parser1->parse($ua);
$parsed2 = $parser2->parse($ua);
// Both should produce identical results
expect($parsed1->browser)->toBe($parsed2->browser);
expect($parsed1->browserVersion->toString())->toBe($parsed2->browserVersion->toString());
// Verify Hash VO is used in cache key (integration point)
// The actual cache key is: 'useragent:' . Hash::create($userAgent, HashAlgorithm::fast())->toString()
$hash = \App\Framework\Core\ValueObjects\Hash::create(
trim($ua),
\App\Framework\Core\ValueObjects\HashAlgorithm::fast()
);
expect($hash->toString())->toBeString();
expect(strlen($hash->toString()))->toBeGreaterThan(0);
expect($hash->getAlgorithm())->toBeInstanceOf(\App\Framework\Core\ValueObjects\HashAlgorithm::class);
});
it('determines modern browser correctly using Version comparison', function () {
$parser = new UserAgentParser();
// Modern Chrome
$modernChrome = $parser->parse('Mozilla/5.0 Chrome/120.0.0.0');
expect($modernChrome->isModern)->toBeTrue();
// Old Chrome (below threshold)
$oldChrome = $parser->parse('Mozilla/5.0 Chrome/50.0.0.0');
expect($oldChrome->isModern)->toBeFalse();
});
it('parses version strings into Version value objects correctly', function () {
$parser = new UserAgentParser();
$ua = 'Mozilla/5.0 Chrome/120.5.3';
$parsed = $parser->parse($ua);
expect($parsed->browserVersion)->toBeInstanceOf(Version::class);
expect($parsed->browserVersion->getMajor())->toBe(120);
expect($parsed->browserVersion->getMinor())->toBe(5);
expect($parsed->browserVersion->getPatch())->toBe(3);
});
it('handles malformed version strings gracefully', function () {
$parser = new UserAgentParser();
// Version with only major
$ua1 = $parser->parse('Mozilla/5.0 Chrome/120');
expect($ua1->browserVersion)->toBeInstanceOf(Version::class);
expect($ua1->browserVersion->getMajor())->toBe(120);
// Version with major.minor
$ua2 = $parser->parse('Mozilla/5.0 Chrome/120.5');
expect($ua2->browserVersion->getMajor())->toBe(120);
expect($ua2->browserVersion->getMinor())->toBe(5);
});
it('provides parser statistics', function () {
$parser = new UserAgentParser();
$stats = $parser->getStats();
expect($stats)->toHaveKey('cacheEnabled');
expect($stats)->toHaveKey('supportedBrowsers');
expect($stats)->toHaveKey('supportedPlatforms');
expect($stats)->toHaveKey('supportedEngines');
expect($stats['cacheEnabled'])->toBeFalse();
expect($stats['supportedBrowsers'])->toBeGreaterThan(0);
});
});