<?php // HttpUriTest.php // Created: 2025-02-28 // Updated: 2025-03-08 declare(strict_types=1); use Index\Http\{HttpRequest,HttpUri}; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\{CoversClass,DataProvider}; use Psr\Http\Message\UriInterface; // based on https://github.com/bakame-php/psr7-uri-interface-tests/blob/5a556fdfe668a6c6a14772efeba6134c0b7dae34/tests/AbstractUriTestCase.php #[CoversClass(HttpRequest::class)] #[CoversClass(HttpUri::class)] final class HttpUriTest extends TestCase { private const string URI = 'http://username:pwd@secure.example.com:443/meow/soap.php?soup=beans#mewow'; /** @return string[][] */ public static function schemeProvider(): array { return [ ['HtTpS', 'https'], // normalized scheme ['http', 'http'], // simple scheme ['', ''], // no scheme ]; } #[DataProvider('schemeProvider')] public function testGetScheme(string $scheme, string $expected): void { $uri = HttpUri::createUri(self::URI)->withScheme($scheme); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getScheme()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->scheme); } /** @return array{string, ?string, string}[] */ public static function userInfoProvider(): array { return [ ['FreakyFurball', 'Express1', 'FreakyFurball:Express1'], // with userinfo ['', '', ''], // no userinfo ['Unko', '', 'Unko:'], // no pass ['flash', null, 'flash'], // pass is null ['Reemo', 'BuddyMan5', 'Reemo:BuddyMan5'], // case sensitive ]; } #[DataProvider('userInfoProvider')] public function testGetUserInfo(string $user, ?string $pass, string $expected): void { $uri = HttpUri::createUri(self::URI)->withUserInfo($user, $pass); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getUserInfo()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->userInfo); } /** @return string[][] */ public static function hostProvider(): array { return [ ['MaStEr.eXaMpLe.CoM', 'master.example.com'], // normalized host ['www.example.com', 'www.example.com'], // simple host ['[::1]', '[::1]'], // IPv6 Host ]; } #[DataProvider('hostProvider')] public function testGetHost(string $host, string $expected): void { $uri = HttpUri::createUri(self::URI)->withHost($host); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getHost()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->host); } /** @return array{string, ?int, ?int}[] */ public static function portProvider(): array { return [ ['http://www.example.com', 443, 443], // non standard port for http ['http://www.example.com', null, null], // remove port ['//www.example.com', 80, 80], // standard port on schemeless http url ]; } #[DataProvider('portProvider')] public function testGetPort(string $uri, ?int $port, ?int $expected): void { $uri = HttpUri::createUri($uri)->withPort($port); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getPort()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->port); } /** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, expected: string}[] */ public static function authorityProvider(): array { return [ [ 'scheme' => 'http', 'user' => 'FreakyFurball', 'pass' => 'Express1', 'host' => 'example.com', 'port' => 443, 'expected' => 'FreakyFurball:Express1@example.com:443', ], [ 'scheme' => 'http', 'user' => 'Reemo', 'pass' => 'BuddyMan5', 'host' => 'example.com', 'port' => null, 'expected' => 'Reemo:BuddyMan5@example.com', ], [ 'scheme' => 'http', 'user' => 'Unko', 'pass' => 'SoapSoapSoapSoapSoap', 'host' => 'example.com', 'port' => 80, 'expected' => 'Unko:SoapSoapSoapSoapSoap@example.com:80', ], [ 'scheme' => 'http', 'user' => 'flash', 'pass' => '', 'host' => 'example.com', 'port' => null, 'expected' => 'flash:@example.com', ], [ 'scheme' => 'http', 'user' => 'Satori', 'pass' => null, 'host' => 'example.com', 'port' => null, 'expected' => 'Satori@example.com', ], [ 'scheme' => 'http', 'user' => '', 'pass' => '', 'host' => 'example.com', 'port' => null, 'expected' => 'example.com', ], ]; } #[DataProvider('authorityProvider')] public function testGetAuthority(string $scheme, string $user, ?string $pass, string $host, ?int $port, string $expected): void { $uri = HttpUri::createUri()->withHost($host)->withScheme($scheme)->withUserInfo($user, $pass)->withPort($port); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getAuthority()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->authority); } /** @return string[][] */ public static function queryProvider(): array { return [ ['foo.bar=%7evalue', 'foo.bar=%7evalue'], // normalized query ['', ''], // empty query ['foo.bar=1&foo.bar=1', 'foo.bar=1&foo.bar=1'], // same param query ['?foo=', '?foo='], // same param query, may include ? ]; } #[DataProvider('queryProvider')] public function testGetQuery(string $query, string $expected): void { $uri = HttpUri::createUri(self::URI)->withQuery($query); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getQuery()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->query); } /** @return string[][] */ public static function fragmentProvider(): array { return [ ['fragment', 'fragment'], // all components ['azAZ0-9/?-._~!$&\'()*+,;=:@', 'azAZ0-9/?-._~!$&\'()*+,;=:@'], // non-encodable ]; } #[DataProvider('fragmentProvider')] public function testGetFragment(string $fragment, string $expected): void { $uri = HttpUri::createUri(self::URI)->withFragment($fragment); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame($expected, $uri->getFragment()); $this->assertInstanceOf(HttpUri::class, $uri); $this->assertSame($expected, $uri->fragment); } /** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, path: string, query: string, fragment: string, expected: string}[] */ public static function stringProvider(): array { return [ [ 'scheme' => 'HtTps', 'user' => 'FreakyFurball', 'pass' => 'Express1', 'host' => 'MeWow.eXaMpLe.CoM', 'port' => 443, 'path' => '/%7ejanedoe/%a1/index.php', 'query' => 'foo.bar=%7evalue', 'fragment' => 'fragment', 'expected' => 'https://FreakyFurball:Express1@mewow.example.com:443/%7ejanedoe/%a1/index.php?foo.bar=%7evalue#fragment' ], [ 'scheme' => '', 'user' => '', 'pass' => '', 'host' => 'www.example.com', 'port' => 443, 'path' => '/foo/bar', 'query' => 'param=value', 'fragment' => 'fragment', 'expected' => '//www.example.com:443/foo/bar?param=value#fragment', ], [ 'scheme' => '', 'user' => '', 'pass' => '', 'host' => '', 'port' => null, 'path' => 'foo/bar', 'query' => '', 'fragment' => '', 'expected' => 'foo/bar', ], ]; } #[DataProvider('stringProvider')] public function testToString( string $scheme, string $user, string $pass, string $host, ?int $port, string $path, string $query, string $fragment, string $expected ): void { $uri = HttpUri::createUri()->withHost($host)->withScheme($scheme) ->withUserInfo($user, $pass)->withPort($port) ->withPath($path)->withQuery($query)->withFragment($fragment); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertInstanceOf(Stringable::class, $uri); // HttpUri also implements this $this->assertSame($expected, (string)$uri); } public function testRemoveScheme(): void { $this->assertSame( '//example.com/this/is/a/path', (string)HttpUri::createUri('http://example.com/this/is/a/path')->withScheme('') ); } public function testRemoveAuthority(): void { $uri = HttpUri::createUri('http://user:login@example.com:82/path?q=v#doc') ->withScheme('')->withUserInfo('')->withPort(null)->withHost(''); $this->assertSame('/path?q=v#doc', (string)$uri); } public function testRemoveUserInfo(): void { $this->assertSame( 'http://example.com/this/is/a/path', (string)HttpUri::createUri('http://user:pass@example.com/this/is/a/path')->withUserInfo('') ); } public function testRemovePort(): void { $this->assertSame( 'http://example.com/this/is/a/path', (string)HttpUri::createUri('http://example.com:9001/this/is/a/path')->withPort(null) ); } public function testRemovePath(): void { $uri = 'http://example.com'; $this->assertSame($uri, (string)HttpUri::createUri($uri . '/this/is/a/path')->withPath('')); } public function testRemoveQuery(): void { $uri = 'http://example.com/this/is/a/path'; $this->assertSame($uri, (string)HttpUri::createUri($uri . '?name=value')->withQuery('')); } public function testRemoveFragment(): void { $uri = 'http://example.com/this/is/a/path'; $this->assertSame($uri, (string)HttpUri::createUri($uri . '#soap')->withFragment('')); } /** @return string[][] */ public static function withSchemeFailProvider(): array { return [ ['un,acceptable'], // unacceptable char ['123'], // number string ]; } #[DataProvider('withSchemeFailProvider')] public function testWithSchemeFail(string $scheme): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri(self::URI)->withScheme($scheme); } /** @return string[][] */ public static function withUserInfoFailProvider(): array { return [ ['mew:ow', 'Soap'], ['be@ns', 'Soup'], ['Meow', 'ok@y'], ]; } #[DataProvider('withUserInfoFailProvider')] public function testWithUserInfoFail(string $user, ?string $pass): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri(self::URI)->withUserInfo($user, $pass); } /** @return string[][] */ public static function withHostFailProvider(): array { return [ ['.example.com'], // dot in front ['host.com-'], // hyphen suffix ['.......'], // multiple dot ['.'], // one dot ['tot. .coucou.com'], // empty label ['re view'], // space in the label ['_bad.host.com'], // underscore in label [implode('', array_fill(0, 12, 'banana')).'.secure.example.com'], // label too long [implode('.', array_fill(0, 128, 'a'))], // too many labels ['[127.0.0.1]'], // Invalid IPv4 format ['[[::1]]'], // Invalid IPv6 format ['[::1'], // Invalid IPv6 format 2 ['example. com'], // space character in starting label ["examp\0le.com"], // invalid character in host label ['[127.2.0.1%253]'], // invalid IP with scope ['ab23::1234%251'], // invalid scope IPv6 ['fe80::1234%25?@'], // invalid scope ID ['fe80::1234%25€'], // invalid scope ID with utf8 character ]; } #[DataProvider('withHostFailProvider')] public function testWithHostFail(string $host): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri(self::URI)->withHost($host); } public function testWithPathFailWithInvalidPathRelativeToAuthority(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withPath('me/ow'); } public function testWithPathFailWithInvalidChars(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withPath('/?'); } public function testWithQueryFailWithInvalidChars(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withQuery('#'); } public function testWithPortFailWithTooLowPort(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withPort(0); } public function testWithPortFailWithTooHighPort(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withPort(0x10000); } public function testWithHostFailWithInvalidHost(): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri('https://example.com')->withHost('?'); } /** @return string[][] */ public static function invalidUriProvider(): array { return [ ['https://user@:443'], [':'], ]; } #[DataProvider('invalidUriProvider')] public function testCreateUriWithInvalidUri(string $uri): void { $this->expectException(InvalidArgumentException::class); HttpUri::createUri($uri); } public function testZeroValues(): void { $expected = '//0:0@host/0?0#0'; $this->assertSame($expected, (string)HttpUri::createUri($expected)); } public function testPathDetection(): void { $expected = 'foo/bar:'; $this->assertSame($expected, HttpUri::createUri($expected)->path); } /** @return array<array{0: string, 1: array<string, list<?string>>}> */ public static function queryStringProvider(): array { return [ [ 'username=meow&password=beans', ['username' => ['meow'], 'password' => ['beans']] ], [ 'username=meow&password=beans&password=soap', ['username' => ['meow'], 'password' => ['beans', 'soap']] ], [ 'arg&arg&arg=maybe&arg&the=ok', ['arg' => [null, null, 'maybe', null], 'the' => ['ok']] ], [ 'array[]=old&array%5B%5D=syntax&array[meow]=soup', ['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']] ], [ 'plus=this+one+uses+plus+as+space&twenty=this%20uses%20percent%20encoding', ['plus' => ['this one uses plus as space'], 'twenty' => ['this uses percent encoding']] ], [ '&&=&&=&', // there's no reason why this shouldn't be valid but it is quirky! ['' => [null, null, '', null, '', null]] ], [ '', [] ], [ ' ', [' ' => [null]] ], ]; } /** @param array<string, string[]> $expected */ #[DataProvider('queryStringProvider')] public function testParseQueryString(string $queryString, array $expected): void { $this->assertEquals(HttpUri::parseQueryString($queryString), $expected); } /** @return array<array{0: array<string, Stringable|scalar|null|list<Stringable|scalar|null>>, 1: string}> */ public static function queryParamsProvider(): array { return [ [ ['username' => ['meow'], 'password' => ['beans']], 'username=meow&password=beans' ], [ ['username' => ['meow'], 'password' => ['beans', 'soap']], 'username=meow&password=beans&password=soap' ], [ ['arg' => [null, null, 'maybe', null], 'the' => ['ok']], 'arg&arg&arg=maybe&arg&the=ok' ], [ ['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']], 'array%5B%5D=old&array%5B%5D=syntax&array%5Bmeow%5D=soup' ], [ ['twenty' => ['this one always uses percent'], 'twenty only' => ['this uses percent encoding']], 'twenty=this%20one%20always%20uses%20percent&twenty%20only=this%20uses%20percent%20encoding' ], [ ['' => [null, null, '', null, '', null]], '&&=&&=&' // there's no reason why this shouldn't be valid but it is quirky! ], [ [], '' ], [ [' ' => [null]], '%20' ], [ // scalar types ['null' => [null], 'int' => [1234], 'float' => [56.78], 'bool' => [true, false], 'string' => ['why not ig']], 'null&int=1234&float=56.78&bool=1&bool=&string=why%20not%20ig' ], [ // stringable ['stringable' => [new class implements Stringable { public function __toString(): string { return 'a'; } }]], 'stringable=a' ], [ // accept non-array ['null' => null, 'int' => 1234, 'float' => 56.78, 'bool' => true, 'string' => 'why not ig'], 'null&int=1234&float=56.78&bool=1&string=why%20not%20ig' ], ]; } /** @param array<string, list<Stringable|scalar|null>> $queryParams */ #[DataProvider('queryParamsProvider')] public function testBuildQueryString(array $queryParams, string $expected): void { $this->assertEquals(HttpUri::buildQueryString($queryParams), $expected); } /** @return array<array{0: string, 1: array<string, string>}> */ public static function cookieStringProvider(): array { return [ [ '', [] ], [ 'soup=meow', ['soup' => 'meow'] ], [ 'soup=meow;the=the1; the2=the3; the4=the5;', ['soup' => 'meow', 'the' => 'the1', 'the2' => 'the3', 'the4' => 'the5'] ], [ 'test%5B%5D=%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82', ['test[]' => 'あああああああ'] ], [ ' =empty', ['' => 'empty'] ], ]; } /** @param array<string, string> $expected */ #[DataProvider('cookieStringProvider')] public function testParseCookieString(string $cookieString, array $expected): void { $this->assertEquals(HttpRequest::parseCookieString($cookieString), $expected); } }