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,242 @@
<?php
declare(strict_types=1);
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Cookies\Cookie;
use App\Framework\Http\Cookies\CookieService;
use App\Framework\Http\Cookies\SameSite;
describe('CookieService', function () {
beforeEach(function () {
$this->environment = new Environment([
EnvKey::APP_ENV->value => 'development'
]);
$this->service = new CookieService($this->environment);
});
describe('create()', function () {
it('creates a standard cookie', function () {
$cookie = $this->service->create(
name: 'session',
value: 'abc123'
);
expect($cookie->name)->toBe('session');
expect($cookie->value)->toBe('abc123');
expect($cookie->partitioned)->toBeFalse();
expect($cookie->sameSite)->toBe(SameSite::Lax);
expect($cookie->httpOnly)->toBeTrue();
});
it('sets secure flag in production', function () {
$prodEnv = new Environment([
EnvKey::APP_ENV->value => 'production'
]);
$service = new CookieService($prodEnv);
$cookie = $service->create('test', 'value');
expect($cookie->secure)->toBeTrue();
});
it('does not set secure flag in development by default', function () {
$cookie = $this->service->create('test', 'value');
expect($cookie->secure)->toBeFalse();
});
it('respects explicit secure parameter', function () {
$cookie = $this->service->create(
name: 'test',
value: 'value',
secure: true
);
expect($cookie->secure)->toBeTrue();
});
it('sets expiration with maxAge', function () {
$cookie = $this->service->create(
name: 'test',
value: 'value',
maxAge: Duration::fromHours(1)
);
expect($cookie->expires)->toBeInt();
expect($cookie->expires)->toBeGreaterThan(time());
});
});
describe('createPartitioned()', function () {
it('creates CHIPS partitioned cookie', function () {
$cookie = $this->service->createPartitioned(
name: 'widget',
value: 'state123'
);
expect($cookie->partitioned)->toBeTrue();
expect($cookie->secure)->toBeTrue();
expect($cookie->sameSite)->toBe(SameSite::None);
expect($cookie->httpOnly)->toBeTrue();
});
it('creates partitioned cookie with custom duration', function () {
$cookie = $this->service->createPartitioned(
name: 'analytics',
value: 'user123',
maxAge: Duration::fromDays(365)
);
expect($cookie->expires)->toBeInt();
expect($cookie->partitioned)->toBeTrue();
});
it('allows httpOnly=false for JavaScript access', function () {
$cookie = $this->service->createPartitioned(
name: 'js_widget',
value: 'data',
httpOnly: false
);
expect($cookie->httpOnly)->toBeFalse();
expect($cookie->partitioned)->toBeTrue();
});
});
describe('createSessionCookie()', function () {
it('creates session cookie without expiration', function () {
$cookie = $this->service->createSessionCookie(
name: 'session_id',
value: 'xyz789'
);
expect($cookie->expires)->toBeNull();
expect($cookie->httpOnly)->toBeTrue();
expect($cookie->sameSite)->toBe(SameSite::Lax);
});
it('allows custom SameSite', function () {
$cookie = $this->service->createSessionCookie(
name: 'session',
value: 'value',
sameSite: SameSite::Strict
);
expect($cookie->sameSite)->toBe(SameSite::Strict);
});
});
describe('createPartitionedSession()', function () {
it('creates partitioned session cookie', function () {
$cookie = $this->service->createPartitionedSession(
name: 'widget_session',
value: 'session123'
);
expect($cookie->expires)->toBeNull();
expect($cookie->partitioned)->toBeTrue();
expect($cookie->secure)->toBeTrue();
expect($cookie->sameSite)->toBe(SameSite::None);
});
});
describe('createRememberMeCookie()', function () {
it('creates remember-me cookie with 30 day default', function () {
$cookie = $this->service->createRememberMeCookie('token123');
expect($cookie->name)->toBe('remember_me');
expect($cookie->value)->toBe('token123');
expect($cookie->httpOnly)->toBeTrue();
expect($cookie->sameSite)->toBe(SameSite::Strict);
expect($cookie->expires)->toBeInt();
});
it('allows custom duration', function () {
$cookie = $this->service->createRememberMeCookie(
value: 'token',
maxAge: Duration::fromDays(7)
);
expect($cookie->expires)->toBeInt();
});
});
describe('createAnalyticsCookie()', function () {
it('creates partitioned analytics cookie', function () {
$cookie = $this->service->createAnalyticsCookie('user123');
expect($cookie->name)->toBe('_analytics');
expect($cookie->value)->toBe('user123');
expect($cookie->partitioned)->toBeTrue();
expect($cookie->httpOnly)->toBeFalse(); // JS access needed
});
});
describe('createWidgetStateCookie()', function () {
it('creates partitioned widget state cookie', function () {
$cookie = $this->service->createWidgetStateCookie(
widgetId: 'chat',
state: '{"minimized":false}'
);
expect($cookie->name)->toBe('widget_chat');
expect($cookie->value)->toBe('{"minimized":false}');
expect($cookie->partitioned)->toBeTrue();
expect($cookie->httpOnly)->toBeTrue();
});
});
describe('delete()', function () {
it('creates deletion cookie with past expiration', function () {
$cookie = $this->service->delete('session');
expect($cookie->name)->toBe('session');
expect($cookie->value)->toBe('');
expect($cookie->expires)->toBeLessThan(time());
});
it('respects custom path', function () {
$cookie = $this->service->delete('session', '/admin');
expect($cookie->path)->toBe('/admin');
});
});
describe('validateSize()', function () {
it('validates cookie within size limit', function () {
$cookie = new Cookie(
name: 'test',
value: 'small_value'
);
expect($this->service->validateSize($cookie))->toBeTrue();
});
it('rejects oversized cookie', function () {
$cookie = new Cookie(
name: 'huge',
value: str_repeat('x', 5000) // Exceeds 4KB limit
);
expect($this->service->validateSize($cookie))->toBeFalse();
});
});
describe('isValidName()', function () {
it('validates correct cookie names', function () {
expect($this->service->isValidName('session_id'))->toBeTrue();
expect($this->service->isValidName('user-token'))->toBeTrue();
expect($this->service->isValidName('TOKEN123'))->toBeTrue();
});
it('rejects invalid cookie names', function () {
expect($this->service->isValidName('session id'))->toBeFalse(); // space
expect($this->service->isValidName('user@token'))->toBeFalse(); // @
expect($this->service->isValidName('token=123'))->toBeFalse(); // =
expect($this->service->isValidName('token;'))->toBeFalse(); // ;
});
});
});

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Cookies\Cookie;
use App\Framework\Http\Cookies\SameSite;
describe('Cookie', function () {
it('creates a basic cookie', function () {
$cookie = new Cookie(
name: 'test',
value: 'value123'
);
expect($cookie->name)->toBe('test');
expect($cookie->value)->toBe('value123');
expect($cookie->partitioned)->toBeFalse();
});
it('generates correct Set-Cookie header', function () {
$cookie = new Cookie(
name: 'session',
value: 'abc123',
expires: 1735689600, // 2025-01-01 00:00:00
path: '/app',
domain: 'example.com',
secure: true,
httpOnly: true,
sameSite: SameSite::Strict
);
$header = $cookie->toHeaderString();
expect($header)->toContain('session=abc123');
expect($header)->toContain('Path=/app');
expect($header)->toContain('Domain=example.com');
expect($header)->toContain('Secure');
expect($header)->toContain('HttpOnly');
expect($header)->toContain('SameSite=Strict');
});
it('creates partitioned cookie with correct attributes', function () {
$cookie = new Cookie(
name: 'widget_session',
value: 'widget123',
secure: true,
sameSite: SameSite::None,
partitioned: true
);
expect($cookie->partitioned)->toBeTrue();
expect($cookie->secure)->toBeTrue();
expect($cookie->sameSite)->toBe(SameSite::None);
$header = $cookie->toHeaderString();
expect($header)->toContain('Partitioned');
expect($header)->toContain('Secure');
expect($header)->toContain('SameSite=None');
});
it('throws exception for partitioned cookie without Secure', function () {
expect(fn () => new Cookie(
name: 'test',
value: 'value',
secure: false, // Invalid!
sameSite: SameSite::None,
partitioned: true
))->toThrow(InvalidArgumentException::class, 'Partitioned cookies require Secure=true and SameSite=None');
});
it('throws exception for partitioned cookie without SameSite=None', function () {
expect(fn () => new Cookie(
name: 'test',
value: 'value',
secure: true,
sameSite: SameSite::Lax, // Invalid!
partitioned: true
))->toThrow(InvalidArgumentException::class, 'Partitioned cookies require Secure=true and SameSite=None');
});
it('throws exception for partitioned cookie with SameSite=Strict', function () {
expect(fn () => new Cookie(
name: 'test',
value: 'value',
secure: true,
sameSite: SameSite::Strict, // Invalid!
partitioned: true
))->toThrow(InvalidArgumentException::class);
});
it('allows non-partitioned cookie with any SameSite', function () {
$cookieLax = new Cookie(
name: 'test1',
value: 'value1',
sameSite: SameSite::Lax,
partitioned: false
);
$cookieStrict = new Cookie(
name: 'test2',
value: 'value2',
sameSite: SameSite::Strict,
partitioned: false
);
expect($cookieLax->sameSite)->toBe(SameSite::Lax);
expect($cookieStrict->sameSite)->toBe(SameSite::Strict);
});
it('encodes cookie name and value in header', function () {
$cookie = new Cookie(
name: 'user_email',
value: 'test@example.com'
);
$header = $cookie->toHeaderString();
expect($header)->toContain(urlencode('user_email'));
expect($header)->toContain(urlencode('test@example.com'));
});
it('converts to string using toHeaderString', function () {
$cookie = new Cookie(
name: 'test',
value: 'value'
);
expect((string) $cookie)->toBe($cookie->toHeaderString());
});
it('does not include Partitioned for non-partitioned cookies', function () {
$cookie = new Cookie(
name: 'standard',
value: 'cookie',
partitioned: false
);
$header = $cookie->toHeaderString();
expect($header)->not->toContain('Partitioned');
});
});

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Url\Rfc3986Url;
use App\Framework\Http\Url\UrlSpec;
describe('Rfc3986Url', function () {
describe('parsing', function () {
it('parses basic HTTP URLs', function () {
$url = Rfc3986Url::parse('https://example.com/path');
expect($url->getScheme())->toBe('https');
expect($url->getHost())->toBe('example.com');
expect($url->getPath())->toBe('/path');
});
it('parses URLs with all components', function () {
$url = Rfc3986Url::parse('https://user:pass@example.com:8080/path?query=1#fragment');
expect($url->getScheme())->toBe('https');
expect($url->getUserInfo())->toBe('user:pass');
expect($url->getHost())->toBe('example.com');
expect($url->getPort())->toBe(8080);
expect($url->getPath())->toBe('/path');
expect($url->getQuery())->toBe('query=1');
expect($url->getFragment())->toBe('fragment');
});
it('handles default ports correctly', function () {
$url = Rfc3986Url::parse('https://example.com/path');
expect($url->getPort())->toBeNull();
});
it('parses relative URLs with base', function () {
$base = Rfc3986Url::parse('https://example.com/base/path');
$url = Rfc3986Url::parse('../other', $base);
expect($url->getPath())->toContain('other');
});
});
describe('spec compliance', function () {
it('reports RFC3986 spec', function () {
$url = Rfc3986Url::parse('https://example.com');
expect($url->getSpec())->toBe(UrlSpec::RFC3986);
expect($url->getSpec()->isRfc3986())->toBeTrue();
expect($url->getSpec()->isWhatwg())->toBeFalse();
});
});
describe('immutable withers', function () {
it('returns new instance with different scheme', function () {
$original = Rfc3986Url::parse('https://example.com');
$modified = $original->withScheme('http');
expect($original->getScheme())->toBe('https');
expect($modified->getScheme())->toBe('http');
expect(spl_object_id($original))->notToBe(spl_object_id($modified));
});
it('returns new instance with different host', function () {
$original = Rfc3986Url::parse('https://example.com');
$modified = $original->withHost('api.example.com');
expect($original->getHost())->toBe('example.com');
expect($modified->getHost())->toBe('api.example.com');
});
it('returns new instance with different port', function () {
$original = Rfc3986Url::parse('https://example.com');
$modified = $original->withPort(8080);
expect($original->getPort())->toBeNull();
expect($modified->getPort())->toBe(8080);
});
it('returns new instance with different path', function () {
$original = Rfc3986Url::parse('https://example.com/old');
$modified = $original->withPath('/new');
expect($original->getPath())->toBe('/old');
expect($modified->getPath())->toBe('/new');
});
it('returns new instance with different query', function () {
$original = Rfc3986Url::parse('https://example.com?old=1');
$modified = $original->withQuery('new=2');
expect($original->getQuery())->toBe('old=1');
expect($modified->getQuery())->toBe('new=2');
});
it('returns new instance with different fragment', function () {
$original = Rfc3986Url::parse('https://example.com#old');
$modified = $original->withFragment('new');
expect($original->getFragment())->toBe('old');
expect($modified->getFragment())->toBe('new');
});
it('returns new instance with user info', function () {
$original = Rfc3986Url::parse('https://example.com');
$modified = $original->withUserInfo('user', 'pass');
expect($original->getUserInfo())->toBe('');
expect($modified->getUserInfo())->toBe('user:pass');
});
it('handles user info without password', function () {
$url = Rfc3986Url::parse('https://example.com');
$modified = $url->withUserInfo('user');
expect($modified->getUserInfo())->toBe('user');
});
});
describe('serialization', function () {
it('converts to string', function () {
$url = Rfc3986Url::parse('https://example.com/path?query=1#fragment');
expect($url->toString())->toBe('https://example.com/path?query=1#fragment');
expect((string) $url)->toBe('https://example.com/path?query=1#fragment');
});
it('converts to ASCII string for IDNA domains', function () {
$url = Rfc3986Url::parse('https://例え.jp/path');
$ascii = $url->toAsciiString();
expect($ascii)->toContain('xn--');
});
});
describe('URL resolution', function () {
it('resolves relative URLs', function () {
$base = Rfc3986Url::parse('https://example.com/base/path');
$resolved = $base->resolve('relative');
expect($resolved->getHost())->toBe('example.com');
expect($resolved->getPath())->toContain('relative');
});
it('resolves absolute URLs', function () {
$base = Rfc3986Url::parse('https://example.com/base');
$resolved = $base->resolve('https://other.com/path');
expect($resolved->getHost())->toBe('other.com');
expect($resolved->getPath())->toBe('/path');
});
});
describe('URL comparison', function () {
it('compares URLs for equality', function () {
$url1 = Rfc3986Url::parse('https://example.com/path');
$url2 = Rfc3986Url::parse('https://example.com/path');
expect($url1->equals($url2))->toBeTrue();
});
it('compares URLs ignoring fragments by default', function () {
$url1 = Rfc3986Url::parse('https://example.com/path#frag1');
$url2 = Rfc3986Url::parse('https://example.com/path#frag2');
expect($url1->equals($url2))->toBeTrue();
expect($url1->equals($url2, includeFragment: true))->toBeFalse();
});
it('detects different URLs', function () {
$url1 = Rfc3986Url::parse('https://example.com/path1');
$url2 = Rfc3986Url::parse('https://example.com/path2');
expect($url1->equals($url2))->toBeFalse();
});
});
describe('edge cases', function () {
it('handles empty path', function () {
$url = Rfc3986Url::parse('https://example.com');
expect($url->getPath())->toBe('');
});
it('handles empty query', function () {
$url = Rfc3986Url::parse('https://example.com/path');
expect($url->getQuery())->toBe('');
});
it('handles empty fragment', function () {
$url = Rfc3986Url::parse('https://example.com/path');
expect($url->getFragment())->toBe('');
});
it('removes port with null', function () {
$url = Rfc3986Url::parse('https://example.com:8080/path');
$modified = $url->withPort(null);
expect($modified->getPort())->toBeNull();
});
it('removes query with empty string', function () {
$url = Rfc3986Url::parse('https://example.com?query=1');
$modified = $url->withQuery('');
expect($modified->getQuery())->toBe('');
});
it('removes fragment with empty string', function () {
$url = Rfc3986Url::parse('https://example.com#fragment');
$modified = $url->withFragment('');
expect($modified->getFragment())->toBe('');
});
});
describe('API client use cases', function () {
it('preserves exact URL structure for API calls', function () {
$url = Rfc3986Url::parse('https://api.example.com/v1/users?filter=active&sort=name');
expect($url->getQuery())->toBe('filter=active&sort=name');
expect($url->toString())->toContain('filter=active&sort=name');
});
it('supports URL signature generation', function () {
$url = Rfc3986Url::parse('https://api.example.com/resource');
$withAuth = $url->withQuery('signature=abc123&timestamp=1234567890');
$canonical = $withAuth->toString();
expect($canonical)->toContain('signature=abc123');
expect($canonical)->toContain('timestamp=1234567890');
});
});
});

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Url\Rfc3986Url;
use App\Framework\Http\Url\UrlFactory;
use App\Framework\Http\Url\UrlSpec;
use App\Framework\Http\Url\UrlUseCase;
use App\Framework\Http\Url\WhatwgUrl;
describe('UrlFactory', function () {
describe('automatic spec selection', function () {
it('selects WHATWG for http/https URLs', function () {
$url = UrlFactory::parse('https://example.com/path');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
expect($url->getSpec())->toBe(UrlSpec::WHATWG);
});
it('selects RFC3986 for non-http schemes', function () {
$url = UrlFactory::parse('ftp://example.com/file');
expect($url)->toBeInstanceOf(Rfc3986Url::class);
expect($url->getSpec())->toBe(UrlSpec::RFC3986);
});
it('selects WHATWG for file:// URLs', function () {
$url = UrlFactory::parse('file:///path/to/file');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('selects WHATWG for WebSocket URLs', function () {
$url = UrlFactory::parse('ws://example.com/socket');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
});
describe('use case factory methods', function () {
it('creates RFC3986 URL for API client', function () {
$url = UrlFactory::forApiClient('https://api.example.com/users');
expect($url)->toBeInstanceOf(Rfc3986Url::class);
expect($url->getHost())->toBe('api.example.com');
expect($url->getPath())->toBe('/users');
});
it('creates RFC3986 URL for cURL request', function () {
$url = UrlFactory::forCurlRequest('https://example.com/endpoint');
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates RFC3986 URL for signature generation', function () {
$url = UrlFactory::forSignature('https://api.example.com/resource');
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates RFC3986 URL for canonical URLs', function () {
$url = UrlFactory::forCanonical('https://example.com/page');
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates WHATWG URL for browser redirect', function () {
$url = UrlFactory::forBrowserRedirect('https://example.com/redirect');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG URL for deep link', function () {
$url = UrlFactory::forDeepLink('myapp://open/profile');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG URL for form action', function () {
$url = UrlFactory::forFormAction('https://example.com/submit');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG URL for client-side JavaScript', function () {
$url = UrlFactory::forClientSide('https://example.com/api/data');
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
});
describe('use case automatic selection', function () {
it('creates RFC3986 for API_CLIENT use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::API_CLIENT,
'https://api.example.com/users'
);
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates RFC3986 for CURL_REQUEST use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::CURL_REQUEST,
'https://example.com/file'
);
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates RFC3986 for SIGNATURE_GENERATION use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::SIGNATURE_GENERATION,
'https://api.example.com/sign'
);
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates RFC3986 for CANONICAL_URL use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::CANONICAL_URL,
'https://example.com/page'
);
expect($url)->toBeInstanceOf(Rfc3986Url::class);
});
it('creates WHATWG for BROWSER_REDIRECT use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::BROWSER_REDIRECT,
'https://example.com/redirect'
);
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG for DEEP_LINK use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::DEEP_LINK,
'myapp://open'
);
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG for HTML_FORM_ACTION use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::HTML_FORM_ACTION,
'https://example.com/submit'
);
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
it('creates WHATWG for CLIENT_SIDE_URL use case', function () {
$url = UrlFactory::forUseCase(
UrlUseCase::CLIENT_SIDE_URL,
'https://example.com/api'
);
expect($url)->toBeInstanceOf(WhatwgUrl::class);
});
});
describe('spec conversion', function () {
it('converts RFC3986 to WHATWG', function () {
$rfc = UrlFactory::forApiClient('https://example.com/path');
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
expect($whatwg)->toBeInstanceOf(WhatwgUrl::class);
expect($whatwg->getHost())->toBe('example.com');
expect($whatwg->getPath())->toBe('/path');
});
it('converts WHATWG to RFC3986', function () {
$whatwg = UrlFactory::forBrowserRedirect('https://example.com/path');
$rfc = UrlFactory::convert($whatwg, UrlSpec::RFC3986);
expect($rfc)->toBeInstanceOf(Rfc3986Url::class);
expect($rfc->getHost())->toBe('example.com');
expect($rfc->getPath())->toBe('/path');
});
it('returns same instance if already correct spec', function () {
$rfc = UrlFactory::forApiClient('https://example.com');
$converted = UrlFactory::convert($rfc, UrlSpec::RFC3986);
expect($converted)->toBe($rfc);
});
});
describe('integration scenarios', function () {
it('creates API client URL with authentication', function () {
$url = UrlFactory::forApiClient('https://api.example.com/users');
$withAuth = $url->withUserInfo('api_key', 'secret');
expect($withAuth->getUserInfo())->toBe('api_key:secret');
expect($withAuth->toString())->toContain('api_key:secret');
});
it('creates redirect URL with query parameters', function () {
$url = UrlFactory::forBrowserRedirect('https://example.com/login');
$withParams = $url->withQuery('return_url=/dashboard&status=success');
expect($withParams->getQuery())->toContain('return_url');
});
it('creates canonical URL without query and fragment', function () {
$url = UrlFactory::forCanonical('https://example.com/page?utm=123#section');
$canonical = $url->withQuery('')->withFragment('');
expect($canonical->toString())->toBe('https://example.com/page');
});
it('creates signature-safe URL', function () {
$url = UrlFactory::forSignature('https://api.example.com/resource');
$withSig = $url->withQuery('timestamp=1234567890&signature=abc123');
$urlString = $withSig->toString();
expect($urlString)->toContain('timestamp=1234567890');
expect($urlString)->toContain('signature=abc123');
});
it('creates deep link with custom data', function () {
$url = UrlFactory::forDeepLink('myapp://open/profile');
$withData = $url->withQuery('user_id=123&action=view');
expect($withData->getScheme())->toBe('myapp');
expect($withData->getQuery())->toContain('user_id=123');
});
});
describe('real-world scenarios', function () {
it('handles OAuth URL construction', function () {
$authUrl = UrlFactory::forApiClient('https://oauth.example.com/authorize');
$withParams = $authUrl->withQuery('client_id=123&redirect_uri=https://app.com&scope=read');
expect($withParams->getQuery())->toContain('client_id=123');
expect($withParams->getQuery())->toContain('redirect_uri');
});
it('handles REST API pagination', function () {
$apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
$page2 = $apiUrl->withQuery('page=2&per_page=50');
expect($page2->getQuery())->toBe('page=2&per_page=50');
});
it('handles mobile deep link with fallback', function () {
$deepLink = UrlFactory::forDeepLink('myapp://open/article/123');
$fallback = UrlFactory::forBrowserRedirect('https://example.com/article/123');
expect($deepLink->getScheme())->toBe('myapp');
expect($fallback->getScheme())->toBe('https');
});
it('handles form submission with CSRF token', function () {
$formAction = UrlFactory::forFormAction('https://example.com/submit');
$withToken = $formAction->withQuery('csrf_token=abc123');
expect($withToken->getQuery())->toContain('csrf_token');
});
});
});

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Url\UrlSpec;
use App\Framework\Http\Url\UrlUseCase;
describe('UrlSpec', function () {
describe('enum values', function () {
it('has RFC3986 case', function () {
expect(UrlSpec::RFC3986->value)->toBe('rfc3986');
});
it('has WHATWG case', function () {
expect(UrlSpec::WHATWG->value)->toBe('whatwg');
});
});
describe('use case mapping', function () {
it('maps API_CLIENT to RFC3986', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::API_CLIENT);
expect($spec)->toBe(UrlSpec::RFC3986);
});
it('maps CURL_REQUEST to RFC3986', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::CURL_REQUEST);
expect($spec)->toBe(UrlSpec::RFC3986);
});
it('maps SIGNATURE_GENERATION to RFC3986', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::SIGNATURE_GENERATION);
expect($spec)->toBe(UrlSpec::RFC3986);
});
it('maps CANONICAL_URL to RFC3986', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::CANONICAL_URL);
expect($spec)->toBe(UrlSpec::RFC3986);
});
it('maps BROWSER_REDIRECT to WHATWG', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::BROWSER_REDIRECT);
expect($spec)->toBe(UrlSpec::WHATWG);
});
it('maps DEEP_LINK to WHATWG', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::DEEP_LINK);
expect($spec)->toBe(UrlSpec::WHATWG);
});
it('maps HTML_FORM_ACTION to WHATWG', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::HTML_FORM_ACTION);
expect($spec)->toBe(UrlSpec::WHATWG);
});
it('maps CLIENT_SIDE_URL to WHATWG', function () {
$spec = UrlSpec::forUseCase(UrlUseCase::CLIENT_SIDE_URL);
expect($spec)->toBe(UrlSpec::WHATWG);
});
});
describe('convenience methods', function () {
it('checks if RFC3986', function () {
expect(UrlSpec::RFC3986->isRfc3986())->toBeTrue();
expect(UrlSpec::WHATWG->isRfc3986())->toBeFalse();
});
it('checks if WHATWG', function () {
expect(UrlSpec::WHATWG->isWhatwg())->toBeTrue();
expect(UrlSpec::RFC3986->isWhatwg())->toBeFalse();
});
});
});
describe('UrlUseCase', function () {
describe('enum cases', function () {
it('has all expected use cases', function () {
$cases = UrlUseCase::cases();
expect($cases)->toHaveCount(8);
expect($cases)->toContain(UrlUseCase::API_CLIENT);
expect($cases)->toContain(UrlUseCase::CURL_REQUEST);
expect($cases)->toContain(UrlUseCase::SIGNATURE_GENERATION);
expect($cases)->toContain(UrlUseCase::CANONICAL_URL);
expect($cases)->toContain(UrlUseCase::BROWSER_REDIRECT);
expect($cases)->toContain(UrlUseCase::DEEP_LINK);
expect($cases)->toContain(UrlUseCase::HTML_FORM_ACTION);
expect($cases)->toContain(UrlUseCase::CLIENT_SIDE_URL);
});
});
describe('descriptions', function () {
it('provides description for API_CLIENT', function () {
$desc = UrlUseCase::API_CLIENT->description();
expect($desc)->toContain('API');
expect($desc)->toContain('REST');
});
it('provides description for BROWSER_REDIRECT', function () {
$desc = UrlUseCase::BROWSER_REDIRECT->description();
expect($desc)->toContain('Browser');
expect($desc)->toContain('redirect');
});
it('provides description for SIGNATURE_GENERATION', function () {
$desc = UrlUseCase::SIGNATURE_GENERATION->description();
expect($desc)->toContain('signature');
expect($desc)->toContain('OAuth');
});
it('provides descriptions for all use cases', function () {
foreach (UrlUseCase::cases() as $useCase) {
expect(strlen($useCase->description()))->toBeGreaterThan(0);
}
});
});
describe('recommended spec', function () {
it('recommends RFC3986 for API_CLIENT', function () {
$spec = UrlUseCase::API_CLIENT->recommendedSpec();
expect($spec)->toBe(UrlSpec::RFC3986);
});
it('recommends WHATWG for BROWSER_REDIRECT', function () {
$spec = UrlUseCase::BROWSER_REDIRECT->recommendedSpec();
expect($spec)->toBe(UrlSpec::WHATWG);
});
it('provides recommendation for all use cases', function () {
foreach (UrlUseCase::cases() as $useCase) {
$spec = $useCase->recommendedSpec();
expect($spec)->toBeInstanceOf(UrlSpec::class);
}
});
});
});

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Url\UrlSpec;
use App\Framework\Http\Url\WhatwgUrl;
describe('WhatwgUrl', function () {
describe('parsing', function () {
it('parses basic HTTP URLs', function () {
$url = WhatwgUrl::parse('https://example.com/path');
expect($url->getScheme())->toBe('https');
expect($url->getHost())->toBe('example.com');
expect($url->getPath())->toBe('/path');
});
it('parses URLs with all components', function () {
$url = WhatwgUrl::parse('https://user:pass@example.com:8080/path?query=1#fragment');
expect($url->getScheme())->toBe('https');
expect($url->getUserInfo())->toBe('user:pass');
expect($url->getHost())->toBe('example.com');
expect($url->getPort())->toBe(8080);
expect($url->getPath())->toBe('/path');
expect($url->getQuery())->toBe('query=1');
expect($url->getFragment())->toBe('fragment');
});
it('handles default ports correctly', function () {
$url = WhatwgUrl::parse('https://example.com/path');
expect($url->getPort())->toBeNull();
});
it('parses relative URLs with base', function () {
$base = WhatwgUrl::parse('https://example.com/base/path');
$url = WhatwgUrl::parse('../other', $base);
expect($url->getPath())->toContain('other');
});
it('normalizes URLs according to WHATWG spec', function () {
$url = WhatwgUrl::parse('https://example.com/./path/../other');
// WHATWG normalizes . and .. in paths
expect($url->getPath())->toBe('/other');
});
});
describe('spec compliance', function () {
it('reports WHATWG spec', function () {
$url = WhatwgUrl::parse('https://example.com');
expect($url->getSpec())->toBe(UrlSpec::WHATWG);
expect($url->getSpec()->isWhatwg())->toBeTrue();
expect($url->getSpec()->isRfc3986())->toBeFalse();
});
});
describe('immutable withers', function () {
it('returns new instance with different scheme', function () {
$original = WhatwgUrl::parse('https://example.com');
$modified = $original->withScheme('http');
expect($original->getScheme())->toBe('https');
expect($modified->getScheme())->toBe('http');
expect(spl_object_id($original))->notToBe(spl_object_id($modified));
});
it('returns new instance with different host', function () {
$original = WhatwgUrl::parse('https://example.com');
$modified = $original->withHost('app.example.com');
expect($original->getHost())->toBe('example.com');
expect($modified->getHost())->toBe('app.example.com');
});
it('returns new instance with different port', function () {
$original = WhatwgUrl::parse('https://example.com');
$modified = $original->withPort(8080);
expect($original->getPort())->toBeNull();
expect($modified->getPort())->toBe(8080);
});
it('returns new instance with different path', function () {
$original = WhatwgUrl::parse('https://example.com/old');
$modified = $original->withPath('/new');
expect($original->getPath())->toBe('/old');
expect($modified->getPath())->toBe('/new');
});
it('returns new instance with different query', function () {
$original = WhatwgUrl::parse('https://example.com?old=1');
$modified = $original->withQuery('new=2');
expect($original->getQuery())->toBe('old=1');
expect($modified->getQuery())->toBe('new=2');
});
it('returns new instance with different fragment', function () {
$original = WhatwgUrl::parse('https://example.com#old');
$modified = $original->withFragment('new');
expect($original->getFragment())->toBe('old');
expect($modified->getFragment())->toBe('new');
});
it('returns new instance with user info', function () {
$original = WhatwgUrl::parse('https://example.com');
$modified = $original->withUserInfo('user', 'pass');
expect($original->getUserInfo())->toBe('');
expect($modified->getUserInfo())->toBe('user:pass');
});
it('handles user info without password', function () {
$url = WhatwgUrl::parse('https://example.com');
$modified = $url->withUserInfo('user');
expect($modified->getUserInfo())->toBe('user');
});
});
describe('serialization', function () {
it('converts to string', function () {
$url = WhatwgUrl::parse('https://example.com/path?query=1#fragment');
expect($url->toString())->toBe('https://example.com/path?query=1#fragment');
expect((string) $url)->toBe('https://example.com/path?query=1#fragment');
});
it('handles IDNA domains (Punycode)', function () {
$url = WhatwgUrl::parse('https://例え.jp/path');
$str = $url->toString();
// WHATWG automatically Punycode-encodes
expect($str)->toContain('xn--');
});
it('toAsciiString returns same as toString for WHATWG', function () {
$url = WhatwgUrl::parse('https://example.com/path');
expect($url->toAsciiString())->toBe($url->toString());
});
});
describe('URL resolution', function () {
it('resolves relative URLs', function () {
$base = WhatwgUrl::parse('https://example.com/base/path');
$resolved = $base->resolve('relative');
expect($resolved->getHost())->toBe('example.com');
expect($resolved->getPath())->toContain('relative');
});
it('resolves absolute URLs', function () {
$base = WhatwgUrl::parse('https://example.com/base');
$resolved = $base->resolve('https://other.com/path');
expect($resolved->getHost())->toBe('other.com');
expect($resolved->getPath())->toBe('/path');
});
it('resolves with path normalization', function () {
$base = WhatwgUrl::parse('https://example.com/base');
$resolved = $base->resolve('./path/../other');
expect($resolved->getPath())->toBe('/other');
});
});
describe('URL comparison', function () {
it('compares URLs for equality', function () {
$url1 = WhatwgUrl::parse('https://example.com/path');
$url2 = WhatwgUrl::parse('https://example.com/path');
expect($url1->equals($url2))->toBeTrue();
});
it('compares URLs ignoring fragments by default', function () {
$url1 = WhatwgUrl::parse('https://example.com/path#frag1');
$url2 = WhatwgUrl::parse('https://example.com/path#frag2');
expect($url1->equals($url2))->toBeTrue();
expect($url1->equals($url2, includeFragment: true))->toBeFalse();
});
it('detects different URLs', function () {
$url1 = WhatwgUrl::parse('https://example.com/path1');
$url2 = WhatwgUrl::parse('https://example.com/path2');
expect($url1->equals($url2))->toBeFalse();
});
});
describe('edge cases', function () {
it('handles trailing slashes', function () {
$url = WhatwgUrl::parse('https://example.com/path/');
expect($url->getPath())->toBe('/path/');
});
it('handles empty path', function () {
$url = WhatwgUrl::parse('https://example.com');
expect($url->getPath())->toBe('/');
});
it('handles empty query', function () {
$url = WhatwgUrl::parse('https://example.com/path');
expect($url->getQuery())->toBe('');
});
it('handles empty fragment', function () {
$url = WhatwgUrl::parse('https://example.com/path');
expect($url->getFragment())->toBe('');
});
it('removes port with null', function () {
$url = WhatwgUrl::parse('https://example.com:8080/path');
$modified = $url->withPort(null);
expect($modified->getPort())->toBeNull();
});
});
describe('browser redirect use cases', function () {
it('handles query parameters for redirects', function () {
$url = WhatwgUrl::parse('https://example.com/redirect');
$withParams = $url->withQuery('return_url=https://other.com&status=success');
expect($withParams->getQuery())->toContain('return_url');
expect($withParams->getQuery())->toContain('status=success');
});
it('handles fragment identifiers', function () {
$url = WhatwgUrl::parse('https://example.com/page#section');
expect($url->getFragment())->toBe('section');
});
it('normalizes paths for browser compatibility', function () {
$url = WhatwgUrl::parse('https://example.com/./path/../other');
expect($url->getPath())->toBe('/other');
});
});
describe('deep link use cases', function () {
it('handles custom schemes', function () {
$url = WhatwgUrl::parse('myapp://open/profile?user_id=123');
expect($url->getScheme())->toBe('myapp');
expect($url->getPath())->toContain('profile');
expect($url->getQuery())->toContain('user_id=123');
});
});
});