From bc58e1e5136d7142cdfcb76bd00658939c1bbdfb Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:50:35 -0500 Subject: [PATCH 01/21] [#19] - aligning php-cs-fixer rules with the other phalcon projects Talon was the only project without ordered_class_elements; vokuro, invo, phalcon, cphalcon, volt and phql all carry a byte-identical block. Its absence is why class members here were never ordered by visibility and name the way the standard describes. declare_strict_types and no_unused_imports are kept as a local addition - phpcs (PSR-12) checks neither, so dropping them to match the shared set exactly would leave both unenforced. They should move into the shared set once the global standard is agreed. --- resources/php-cs-fixer.php | 59 +++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/resources/php-cs-fixer.php b/resources/php-cs-fixer.php index 44bc681..f118601 100644 --- a/resources/php-cs-fixer.php +++ b/resources/php-cs-fixer.php @@ -15,17 +15,50 @@ ->in([__DIR__ . '/../src', __DIR__ . '/../tests']); return (new PhpCsFixer\Config()) + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + // declare_strict_types is a risky rule. ->setRiskyAllowed(true) - ->setRules([ - '@PSR12' => true, - 'declare_strict_types' => true, - 'blank_line_between_import_groups' => true, - 'ordered_imports' => [ - 'sort_algorithm' => 'alpha', - 'imports_order' => ['class', 'function', 'const'], - ], - 'no_unused_imports' => true, - 'array_syntax' => ['syntax' => 'short'], - ]) - ->setFinder($finder) - ->setCacheFile(__DIR__ . '/../tests/_output/.php-cs-fixer.cache'); + ->setUsingCache(true) + ->setCacheFile(__DIR__ . '/../tests/_output/.php-cs-fixer.cache') + ->setRules( + [ + // The two rules below are a local addition on top of the ordering + // rules shared with the other Phalcon projects. They are kept here + // until the global coding standard is agreed, at which point they + // should move into the shared set rather than stay a divergence. + // PSR-12 (via phpcs) checks neither, so nothing else enforces them. + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'ordered_class_elements' => [ + 'sort_algorithm' => 'alpha', + 'order' => [ + 'use_trait', + 'case', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_protected', + 'method_private', + ], + ], + ] + ) + ->setFinder($finder); From 811b5f22072cde4b24aae76c18b0984248ced21a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:51:07 -0500 Subject: [PATCH 02/21] [#19] - applying ordered_class_elements across the codebase Pure formatting: no behaviour changes. This is the one-off cost of adding the rule - every subsequent change is kept in order by composer cs-fixer-fix rather than by hand. Reordering moves members past each other, which leaves PSR-12 blank-line artifacts that php-cs-fixer does not own; phpcbf (composer cs-fix) settled those. Run the two in that order after any future reorder. --- src/Contracts/Settings.php | 7 +- src/PHPUnit/AbstractBrowserTestCase.php | 2 +- src/PHPUnit/AbstractFunctionalTestCase.php | 2 +- src/PHPUnit/AbstractUnitTestCase.php | 2 +- src/Settings.php | 126 ++--- src/Traits/BrowserTrait.php | 4 +- src/Traits/FunctionalAssertionsTrait.php | 3 +- src/Traits/FunctionalTrait.php | 4 +- tests/Fakes/Browser/FixtureSmokeTest.php | 10 +- tests/Traits/BrowserTraitTest.php | 171 +++--- tests/Traits/DatabaseTraitTest.php | 32 +- .../FunctionalInvalidApplicationTest.php | 10 +- tests/Traits/FunctionalMissingServiceTest.php | 12 +- tests/Traits/FunctionalNullDiTest.php | 20 +- tests/Traits/FunctionalRealNullDiTest.php | 32 +- tests/Traits/FunctionalTraitTest.php | 86 +-- tests/Traits/ResultSetTraitTest.php | 52 +- tests/Traits/ServicesTraitTest.php | 30 +- tests/Unit/Bootstrap/RunnerTest.php | 70 +-- tests/Unit/Browser/ClientTest.php | 149 +++--- tests/Unit/Cli/InputTest.php | 15 +- tests/Unit/Cli/SuiteMapTest.php | 53 +- tests/Unit/Database/ConnectionTest.php | 28 +- tests/Unit/Database/StatementSplitterTest.php | 55 +- tests/Unit/EnvironmentTest.php | 9 +- .../PHPUnit/AbstractBrowserTestCaseTest.php | 9 +- .../AbstractFunctionalTestCaseTest.php | 9 +- .../Unit/PHPUnit/AbstractUnitTestCaseTest.php | 127 +++-- tests/Unit/SettingsTest.php | 493 +++++++++--------- tests/Unit/TalonTest.php | 28 +- 30 files changed, 819 insertions(+), 831 deletions(-) diff --git a/src/Contracts/Settings.php b/src/Contracts/Settings.php index bf887b1..872c77d 100644 --- a/src/Contracts/Settings.php +++ b/src/Contracts/Settings.php @@ -15,6 +15,9 @@ interface Settings { + public function cachePath(string $relative = ''): string; + + public function dataPath(string $relative = ''): string; public function get(string $key, mixed $default = null): mixed; public function getDatabaseDsn(string $driver): string; @@ -29,10 +32,6 @@ public function getDatabaseOptions(string $driver): array; */ public function getServiceOptions(string $name): array; - public function cachePath(string $relative = ''): string; - - public function dataPath(string $relative = ''): string; - public function logsPath(string $relative = ''): string; public function outputPath(string $relative = ''): string; diff --git a/src/PHPUnit/AbstractBrowserTestCase.php b/src/PHPUnit/AbstractBrowserTestCase.php index 2b9f0e3..ba4352a 100644 --- a/src/PHPUnit/AbstractBrowserTestCase.php +++ b/src/PHPUnit/AbstractBrowserTestCase.php @@ -18,8 +18,8 @@ abstract class AbstractBrowserTestCase extends AbstractUnitTestCase { - use BrowserTrait; use BrowserAssertionsTrait; + use BrowserTrait; protected function setUp(): void { diff --git a/src/PHPUnit/AbstractFunctionalTestCase.php b/src/PHPUnit/AbstractFunctionalTestCase.php index 62f155a..03aa2d5 100644 --- a/src/PHPUnit/AbstractFunctionalTestCase.php +++ b/src/PHPUnit/AbstractFunctionalTestCase.php @@ -18,6 +18,6 @@ abstract class AbstractFunctionalTestCase extends AbstractUnitTestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; } diff --git a/src/PHPUnit/AbstractUnitTestCase.php b/src/PHPUnit/AbstractUnitTestCase.php index 852d67b..23d7da0 100644 --- a/src/PHPUnit/AbstractUnitTestCase.php +++ b/src/PHPUnit/AbstractUnitTestCase.php @@ -32,8 +32,8 @@ abstract class AbstractUnitTestCase extends TestCase { - use ReflectionTrait; use FileSystemTrait; + use ReflectionTrait; protected function setUp(): void { diff --git a/src/Settings.php b/src/Settings.php index 7982b4c..3cd6184 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -174,6 +174,69 @@ public static function fromEnv(array $overrides = []): self ); } + private static function discoverRoot(): string + { + $start = getcwd() ?: '.'; + $current = $start; + + while (true) { + if (file_exists($current . '/composer.json')) { + return $current; + } + + $parent = dirname($current); + if ($parent === $current) { + return $start; + } + + $current = $parent; + } + } + + /** + * @param array $config + * + * @return array + */ + private static function section(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + return []; + } + + /** @var array $value */ + return $value; + } + + /** + * @param array $config + * + * @return array> + */ + private static function sectionOfArrays(array $config, string $key): array + { + $result = []; + foreach (self::section($config, $key) as $name => $value) { + if (is_array($value)) { + /** @var array $value */ + $result[(string) $name] = $value; + } + } + + return $result; + } + + public function cachePath(string $relative = ''): string + { + return $this->dir('cache', 'tests/_output/cache', $relative); + } + + public function dataPath(string $relative = ''): string + { + return $this->dir('data', 'tests/_data', $relative); + } + public function get(string $key, mixed $default = null): mixed { return $this->extra[$key] ?? $default; @@ -222,16 +285,6 @@ public function getServiceOptions(string $name): array return ($this->services[$name] ?? null)?->getOptions() ?? []; } - public function cachePath(string $relative = ''): string - { - return $this->dir('cache', 'tests/_output/cache', $relative); - } - - public function dataPath(string $relative = ''): string - { - return $this->dir('data', 'tests/_data', $relative); - } - public function logsPath(string $relative = ''): string { return $this->dir('logs', 'tests/_output/logs', $relative); @@ -275,25 +328,6 @@ private function dir(string $key, string $default, string $relative): string return $this->rootPath($sub); } - private static function discoverRoot(): string - { - $start = getcwd() ?: '.'; - $current = $start; - - while (true) { - if (file_exists($current . '/composer.json')) { - return $current; - } - - $parent = dirname($current); - if ($parent === $current) { - return $start; - } - - $current = $parent; - } - } - /** * @param array $options */ @@ -303,38 +337,4 @@ private function optString(array $options, string $key, string $default = ''): s return is_scalar($value) ? (string) $value : $default; } - - /** - * @param array $config - * - * @return array - */ - private static function section(array $config, string $key): array - { - $value = $config[$key] ?? []; - if (!is_array($value)) { - return []; - } - - /** @var array $value */ - return $value; - } - - /** - * @param array $config - * - * @return array> - */ - private static function sectionOfArrays(array $config, string $key): array - { - $result = []; - foreach (self::section($config, $key) as $name => $value) { - if (is_array($value)) { - /** @var array $value */ - $result[(string) $name] = $value; - } - } - - return $result; - } } diff --git a/src/Traits/BrowserTrait.php b/src/Traits/BrowserTrait.php index bd35b66..52646db 100644 --- a/src/Traits/BrowserTrait.php +++ b/src/Traits/BrowserTrait.php @@ -33,8 +33,6 @@ trait BrowserTrait private ?Form $form = null; - abstract protected function appFactory(): callable; - public function clickLink(string $text, ?string $context = null): void { $crawler = null === $context @@ -90,6 +88,8 @@ public function visitPage(string $url): void $this->form = null; } + abstract protected function appFactory(): callable; + protected function browser(): Client { if (null === $this->client) { diff --git a/src/Traits/FunctionalAssertionsTrait.php b/src/Traits/FunctionalAssertionsTrait.php index 19a1157..fe36637 100644 --- a/src/Traits/FunctionalAssertionsTrait.php +++ b/src/Traits/FunctionalAssertionsTrait.php @@ -23,8 +23,6 @@ */ trait FunctionalAssertionsTrait { - abstract public function getContent(): string; - public function assertAction(string $expected): void { $this->assertSame($expected, $this->dispatcher()->getActionName()); @@ -71,6 +69,7 @@ public function assertResponseContentContains(string $needle): void { $this->assertStringContainsString($needle, $this->getContent()); } + abstract public function getContent(): string; abstract protected function dispatcher(): Dispatcher; diff --git a/src/Traits/FunctionalTrait.php b/src/Traits/FunctionalTrait.php index 9dc7d39..984b5ee 100644 --- a/src/Traits/FunctionalTrait.php +++ b/src/Traits/FunctionalTrait.php @@ -36,8 +36,6 @@ trait FunctionalTrait private mixed $response = null; - abstract protected function appFactory(): callable; - public function dispatch(string $url): void { $factory = $this->appFactory(); @@ -56,6 +54,8 @@ public function getContent(): string return $this->response()->getContent(); } + abstract protected function appFactory(): callable; + protected function dispatcher(): Dispatcher { $dispatcher = $this->di()->getShared('dispatcher'); diff --git a/tests/Fakes/Browser/FixtureSmokeTest.php b/tests/Fakes/Browser/FixtureSmokeTest.php index 128dd86..065a64a 100644 --- a/tests/Fakes/Browser/FixtureSmokeTest.php +++ b/tests/Fakes/Browser/FixtureSmokeTest.php @@ -20,11 +20,6 @@ final class FixtureSmokeTest extends TestCase { use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/app.php'; - } - public function testFormRendersWithCsrfField(): void { $this->dispatch('/browser/form'); @@ -40,4 +35,9 @@ public function testSecuredShowsGuestWithoutSession(): void $this->assertStringContainsString('Guest', $this->getContent()); } + + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/app.php'; + } } diff --git a/tests/Traits/BrowserTraitTest.php b/tests/Traits/BrowserTraitTest.php index cea347a..b1c902c 100644 --- a/tests/Traits/BrowserTraitTest.php +++ b/tests/Traits/BrowserTraitTest.php @@ -21,19 +21,6 @@ final class BrowserTraitTest extends AbstractBrowserTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../Fakes/Browser/app.php'; - } - - public function testVisitAndAssertPageText(): void - { - $this->visitPage('/browser/form'); - - $this->assertPageContainsText('Log In'); - $this->assertPageMissingText('Welcome back'); - } - public function testAssertBeforeVisitThrows(): void { $this->expectException(ResponseNotDispatched::class); @@ -41,41 +28,13 @@ public function testAssertBeforeVisitThrows(): void $this->assertPageContainsText('anything'); } - public function testLoginFlowFollowsRedirectAndKeepsSession(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'sarah'); - $this->selectOption('active', 'Yes'); - $this->pressButton('Log In'); - - $this->assertPageContainsText('Welcome sarah'); - $this->assertPageContainsText('active=Yes'); - } - - public function testSessionPersistsAcrossASecondRequest(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'sarah'); - $this->pressButton('Log In'); - - $this->visitPage('/browser/secured'); - $this->assertPageContainsText('Welcome sarah'); - } - - public function testSecuredIsGuestInAFreshTest(): void + public function testAssertPageMissingTextFailsWhenTextIsPresent(): void { - $this->visitPage('/browser/secured'); - - $this->assertPageContainsText('Guest'); - } + $this->visitPage('/browser/landed'); - public function testPressButtonBySelectorSubmitsTheForm(): void - { - $this->visitPage('/browser/form'); - $this->fillField('name', 'john'); - $this->pressButton('//form/*[@type="submit"]'); + $this->expectException(AssertionFailedError::class); - $this->assertPageContainsText('Welcome john'); + $this->assertPageMissingText('landed ok'); } public function testClickLinkByText(): void @@ -94,14 +53,6 @@ public function testClickLinkWithinContext(): void $this->assertPageContainsText('landed ok'); } - public function testPressButtonByLabelWithoutFilling(): void - { - $this->visitPage('/browser/search'); - $this->pressButton('Search'); - - $this->assertPageContainsText('landed ok'); - } - public function testCookieJarReadAndWrite(): void { $this->visitPage('/browser/cookie'); @@ -112,75 +63,72 @@ public function testCookieJarReadAndWrite(): void $this->assertPageContainsText('cookie sent=crunchy'); } - public function testSetCookieIsClearedWhenTheAppExpiresIt(): void + public function testFillFieldWithNoFormThrows(): void { - // A set cookie is host-scoped, so a response that expires it on the same - // host actually evicts it from the jar (an empty-domain cookie would not). - $this->setCookie('talon', 'crunchy'); - $this->assertSame('crunchy', $this->getCookie('talon')); + $this->visitPage('/browser/landed'); - $this->visitPage('/browser/cookieClear'); + $this->expectException(ElementNotFound::class); - $this->assertNull($this->getCookie('talon')); + $this->fillField('name', 'value'); } - public function testMissingLinkThrows(): void + public function testGetCookieReturnsNullWhenAbsent(): void { $this->visitPage('/browser/menu'); - $this->expectException(ElementNotFound::class); - $this->expectExceptionMessage('Could not find link "Nonexistent" on the page'); - - $this->clickLink('Nonexistent'); + $this->assertNull($this->getCookie('nope')); } - public function testGetCookieReturnsNullWhenAbsent(): void + public function testLoginFlowFollowsRedirectAndKeepsSession(): void { - $this->visitPage('/browser/menu'); + $this->visitPage('/browser/form'); + $this->fillField('name', 'sarah'); + $this->selectOption('active', 'Yes'); + $this->pressButton('Log In'); - $this->assertNull($this->getCookie('nope')); + $this->assertPageContainsText('Welcome sarah'); + $this->assertPageContainsText('active=Yes'); } - public function testPressButtonWithNoFormThrows(): void + public function testMissingLinkThrows(): void { - $this->visitPage('/browser/landed'); + $this->visitPage('/browser/menu'); $this->expectException(ElementNotFound::class); - $this->expectExceptionMessage('Could not find button "Save" on the page'); + $this->expectExceptionMessage('Could not find link "Nonexistent" on the page'); - $this->pressButton('Save'); + $this->clickLink('Nonexistent'); } - public function testPressButtonByXPathWithoutFilling(): void + public function testPressButtonByLabelWithoutFilling(): void { $this->visitPage('/browser/search'); - $this->pressButton('//button'); + $this->pressButton('Search'); $this->assertPageContainsText('landed ok'); } - public function testFillFieldWithNoFormThrows(): void + public function testPressButtonByParenthesizedXPath(): void { - $this->visitPage('/browser/landed'); - - $this->expectException(ElementNotFound::class); + $this->visitPage('/browser/search'); + $this->pressButton('(//button)[1]'); - $this->fillField('name', 'value'); + $this->assertPageContainsText('landed ok'); } - public function testAssertPageMissingTextFailsWhenTextIsPresent(): void + public function testPressButtonBySelectorSubmitsTheForm(): void { - $this->visitPage('/browser/landed'); - - $this->expectException(AssertionFailedError::class); + $this->visitPage('/browser/form'); + $this->fillField('name', 'john'); + $this->pressButton('//form/*[@type="submit"]'); - $this->assertPageMissingText('landed ok'); + $this->assertPageContainsText('Welcome john'); } - public function testPressButtonByParenthesizedXPath(): void + public function testPressButtonByXPathWithoutFilling(): void { $this->visitPage('/browser/search'); - $this->pressButton('(//button)[1]'); + $this->pressButton('//button'); $this->assertPageContainsText('landed ok'); } @@ -196,6 +144,16 @@ public function testPressButtonDoesNotTreatPlainLabelsAsXPath(): void $this->pressButton('button'); } + public function testPressButtonWithNoFormThrows(): void + { + $this->visitPage('/browser/landed'); + + $this->expectException(ElementNotFound::class); + $this->expectExceptionMessage('Could not find button "Save" on the page'); + + $this->pressButton('Save'); + } + public function testPressButtonWithUnmatchedXPathThrows(): void { $this->visitPage('/browser/search'); @@ -205,6 +163,43 @@ public function testPressButtonWithUnmatchedXPathThrows(): void $this->pressButton('//form/input[@type="submit"]'); } + public function testSecuredIsGuestInAFreshTest(): void + { + $this->visitPage('/browser/secured'); + + $this->assertPageContainsText('Guest'); + } + + public function testSessionPersistsAcrossASecondRequest(): void + { + $this->visitPage('/browser/form'); + $this->fillField('name', 'sarah'); + $this->pressButton('Log In'); + + $this->visitPage('/browser/secured'); + $this->assertPageContainsText('Welcome sarah'); + } + + public function testSetCookieIsClearedWhenTheAppExpiresIt(): void + { + // A set cookie is host-scoped, so a response that expires it on the same + // host actually evicts it from the jar (an empty-domain cookie would not). + $this->setCookie('talon', 'crunchy'); + $this->assertSame('crunchy', $this->getCookie('talon')); + + $this->visitPage('/browser/cookieClear'); + + $this->assertNull($this->getCookie('talon')); + } + + public function testVisitAndAssertPageText(): void + { + $this->visitPage('/browser/form'); + + $this->assertPageContainsText('Log In'); + $this->assertPageMissingText('Welcome back'); + } + public function testVisitPageAlwaysTargetsLocalhost(): void { // Even with another default host configured, visitPage() pins the @@ -217,4 +212,8 @@ public function testVisitPageAlwaysTargetsLocalhost(): void $this->assertInstanceOf(Request::class, $request); $this->assertSame('http://localhost/browser/landed', $request->getUri()); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../Fakes/Browser/app.php'; + } } diff --git a/tests/Traits/DatabaseTraitTest.php b/tests/Traits/DatabaseTraitTest.php index 6a65833..5418e46 100644 --- a/tests/Traits/DatabaseTraitTest.php +++ b/tests/Traits/DatabaseTraitTest.php @@ -45,17 +45,17 @@ protected function tearDown(): void parent::tearDown(); } - public function testAssertInDatabasePasses(): void - { - $this->assertInDatabase('users', ['id' => 1]); - } - public function testAssertInDatabaseFailsWhenMissing(): void { $this->expectException(AssertionFailedError::class); $this->assertInDatabase('users', ['id' => 999]); } + public function testAssertInDatabasePasses(): void + { + $this->assertInDatabase('users', ['id' => 1]); + } + public function testAssertNotInDatabasePasses(): void { $this->assertNotInDatabase('users', ['id' => 999]); @@ -73,6 +73,17 @@ public function testGetConnectionLoadsSchemaOnceWhenDumpFileConfigured(): void $this->assertInDatabase('seeded_users', ['id' => 1]); } + public function testGetDriverReturnsCurrentEnvDriver(): void + { + putenv('driver=pgsql'); + + try { + $this->assertSame('pgsql', $this->getDriver()); + } finally { + putenv('driver'); + } + } + public function testPublicApiVisibility(): void { // Execute each helper so this test covers the mutated method bodies @@ -103,15 +114,4 @@ public function testPublicApiVisibility(): void ); } } - - public function testGetDriverReturnsCurrentEnvDriver(): void - { - putenv('driver=pgsql'); - - try { - $this->assertSame('pgsql', $this->getDriver()); - } finally { - putenv('driver'); - } - } } diff --git a/tests/Traits/FunctionalInvalidApplicationTest.php b/tests/Traits/FunctionalInvalidApplicationTest.php index e463ade..57076d7 100644 --- a/tests/Traits/FunctionalInvalidApplicationTest.php +++ b/tests/Traits/FunctionalInvalidApplicationTest.php @@ -22,15 +22,15 @@ final class FunctionalInvalidApplicationTest extends TestCase { use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => new stdClass(); - } - public function testDispatchRejectsAppWithoutHandle(): void { $this->expectException(InvalidApplication::class); $this->dispatch('/'); } + + protected function appFactory(): callable + { + return static fn () => new stdClass(); + } } diff --git a/tests/Traits/FunctionalMissingServiceTest.php b/tests/Traits/FunctionalMissingServiceTest.php index e21fcc9..c6f15f4 100644 --- a/tests/Traits/FunctionalMissingServiceTest.php +++ b/tests/Traits/FunctionalMissingServiceTest.php @@ -21,13 +21,8 @@ final class FunctionalMissingServiceTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; - - protected function appFactory(): callable - { - return static fn (): object => new FakeAppWithMissingDispatcher(); - } + use FunctionalTrait; public function testMissingDispatcherThrows(): void { @@ -37,4 +32,9 @@ public function testMissingDispatcherThrows(): void $this->assertController('test'); } + + protected function appFactory(): callable + { + return static fn (): object => new FakeAppWithMissingDispatcher(); + } } diff --git a/tests/Traits/FunctionalNullDiTest.php b/tests/Traits/FunctionalNullDiTest.php index 77f493c..6aa5f6d 100644 --- a/tests/Traits/FunctionalNullDiTest.php +++ b/tests/Traits/FunctionalNullDiTest.php @@ -22,8 +22,17 @@ final class FunctionalNullDiTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; + + public function testNullDiThrows(): void + { + $this->dispatch('/test/hello'); + + $this->expectException(ResponseNotDispatched::class); + + $this->assertController('test'); + } protected function appFactory(): callable { @@ -34,13 +43,4 @@ protected function resolveDi(InjectionAwareInterface $application): DiInterface { throw new ResponseNotDispatched(); } - - public function testNullDiThrows(): void - { - $this->dispatch('/test/hello'); - - $this->expectException(ResponseNotDispatched::class); - - $this->assertController('test'); - } } diff --git a/tests/Traits/FunctionalRealNullDiTest.php b/tests/Traits/FunctionalRealNullDiTest.php index 82e70ed..042b847 100644 --- a/tests/Traits/FunctionalRealNullDiTest.php +++ b/tests/Traits/FunctionalRealNullDiTest.php @@ -32,8 +32,23 @@ */ final class FunctionalRealNullDiTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; + + public function testRealResolveDiThrowsWhenGetDiReturnsNull(): void + { + if (!Environment::viaImplementation()) { + $this->markTestSkipped( + 'InjectionAwareInterface::getDI() is only nullable under the phalcon/phalcon (v6) provider' + ); + } + + $this->dispatch('/test/hello'); + + $this->expectException(ResponseNotDispatched::class); + + $this->assertController('test'); + } protected function appFactory(): callable { @@ -55,19 +70,4 @@ public function setDI(DiInterface $di): void }; }; } - - public function testRealResolveDiThrowsWhenGetDiReturnsNull(): void - { - if (!Environment::viaImplementation()) { - $this->markTestSkipped( - 'InjectionAwareInterface::getDI() is only nullable under the phalcon/phalcon (v6) provider' - ); - } - - $this->dispatch('/test/hello'); - - $this->expectException(ResponseNotDispatched::class); - - $this->assertController('test'); - } } diff --git a/tests/Traits/FunctionalTraitTest.php b/tests/Traits/FunctionalTraitTest.php index bd87722..8705367 100644 --- a/tests/Traits/FunctionalTraitTest.php +++ b/tests/Traits/FunctionalTraitTest.php @@ -27,52 +27,40 @@ final class FunctionalTraitTest extends TestCase { - use FunctionalTrait; use FunctionalAssertionsTrait; + use FunctionalTrait; - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../Fakes/App/app.php'; - } - - public function testDispatchAndAssertControllerAction(): void + public function testAssertActionFailsOnMismatch(): void { $this->dispatch('/test/hello'); - $this->assertController('test'); - $this->assertAction('hello'); - $this->assertResponseContentContains('Operator'); - $this->assertStringContainsString('Operator', $this->getContent()); - } - - public function testAssertHeader(): void - { - $this->dispatch('/test/header'); + $this->expectException(AssertionFailedError::class); - $this->assertHeader(['X-Talon' => 'yes']); + $this->assertAction('other'); } - public function testAssertResponseCode(): void + public function testAssertDispatchIsForwarded(): void { - $this->dispatch('/test/status'); + $this->dispatch('/test/forward'); - $this->assertResponseCode(404); - $this->assertResponseCode('404'); + $this->assertDispatchIsForwarded(); + $this->assertAction('empty'); } - public function testAssertRedirectTo(): void + public function testAssertDispatchIsForwardedFailsWhenNotForwarded(): void { - $this->dispatch('/test/redirect'); + $this->dispatch('/test/hello'); - $this->assertRedirectTo('/target'); + $this->expectException(AssertionFailedError::class); + + $this->assertDispatchIsForwarded(); } - public function testAssertDispatchIsForwarded(): void + public function testAssertHeader(): void { - $this->dispatch('/test/forward'); + $this->dispatch('/test/header'); - $this->assertDispatchIsForwarded(); - $this->assertAction('empty'); + $this->assertHeader(['X-Talon' => 'yes']); } public function testAssertionBeforeDispatchThrows(): void @@ -82,47 +70,54 @@ public function testAssertionBeforeDispatchThrows(): void $this->assertController('test'); } - public function testGetContentBeforeDispatchThrows(): void + public function testAssertRedirectTo(): void { - $this->expectException(ResponseNotDispatched::class); + $this->dispatch('/test/redirect'); - $this->getContent(); + $this->assertRedirectTo('/target'); } - public function testAssertActionFailsOnMismatch(): void + public function testAssertResponseCode(): void { - $this->dispatch('/test/hello'); - - $this->expectException(AssertionFailedError::class); + $this->dispatch('/test/status'); - $this->assertAction('other'); + $this->assertResponseCode(404); + $this->assertResponseCode('404'); } - public function testAssertDispatchIsForwardedFailsWhenNotForwarded(): void + public function testAssertResponseCodeFailsWhenStatusHeaderMissing(): void { $this->dispatch('/test/hello'); $this->expectException(AssertionFailedError::class); - $this->assertDispatchIsForwarded(); + $this->assertResponseCode(404); } - public function testAssertResponseCodeFailsWhenStatusHeaderMissing(): void + public function testAssertResponseContentContainsFailsOnMissingNeedle(): void { $this->dispatch('/test/hello'); $this->expectException(AssertionFailedError::class); - $this->assertResponseCode(404); + $this->assertResponseContentContains('absent'); } - public function testAssertResponseContentContainsFailsOnMissingNeedle(): void + public function testDispatchAndAssertControllerAction(): void { $this->dispatch('/test/hello'); - $this->expectException(AssertionFailedError::class); + $this->assertController('test'); + $this->assertAction('hello'); + $this->assertResponseContentContains('Operator'); + $this->assertStringContainsString('Operator', $this->getContent()); + } - $this->assertResponseContentContains('absent'); + public function testGetContentBeforeDispatchThrows(): void + { + $this->expectException(ResponseNotDispatched::class); + + $this->getContent(); } public function testProtectedHelpersAreAccessibleFromSubclass(): void @@ -176,4 +171,9 @@ public function testPublicApiIsCallableFromOutside(): void $fixture->dispatch('/test/status'); $fixture->assertResponseCode(404); } + + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../Fakes/App/app.php'; + } } diff --git a/tests/Traits/ResultSetTraitTest.php b/tests/Traits/ResultSetTraitTest.php index 516904a..44cc22d 100644 --- a/tests/Traits/ResultSetTraitTest.php +++ b/tests/Traits/ResultSetTraitTest.php @@ -24,6 +24,25 @@ final class ResultSetTraitTest extends TestCase { use ResultSetTrait; + public function testAcceptsResultsetSubclass(): void + { + $this->assertInstanceOf(Simple::class, $this->mockResultSet([], Simple::class)); + } + + public function testEmptyMock(): void + { + $mock = $this->mockResultSet([]); + + $this->assertCount(0, $mock); + $this->assertNull($mock->getFirst()); + } + + public function testInvalidClassThrows(): void + { + $this->expectException(InvalidResultsetClass::class); + $this->mockResultSet([], self::class); + } + public function testMockReportsCountAndFirstLast(): void { $first = $this->createMock(ModelInterface::class); @@ -38,12 +57,15 @@ public function testMockReportsCountAndFirstLast(): void $this->assertSame([$first, $middle, $last], $mock->toArray()); } - public function testEmptyMock(): void + public function testMockResultSetIsPublic(): void { - $mock = $this->mockResultSet([]); + // The call covers the method body so infection pairs this test + // with the visibility mutant; the reflection check observes it. + $this->mockResultSet([]); - $this->assertCount(0, $mock); - $this->assertNull($mock->getFirst()); + $this->assertTrue( + (new ReflectionMethod(self::class, 'mockResultSet'))->isPublic() + ); } public function testMockSupportsIteration(): void @@ -62,23 +84,6 @@ public function testMockSupportsIteration(): void $this->assertFalse($mock->valid()); } - public function testInvalidClassThrows(): void - { - $this->expectException(InvalidResultsetClass::class); - $this->mockResultSet([], self::class); - } - - public function testMockResultSetIsPublic(): void - { - // The call covers the method body so infection pairs this test - // with the visibility mutant; the reflection check observes it. - $this->mockResultSet([]); - - $this->assertTrue( - (new ReflectionMethod(self::class, 'mockResultSet'))->isPublic() - ); - } - public function testSeekReadsTheInjectedRows(): void { $first = $this->createMock(ModelInterface::class); @@ -91,9 +96,4 @@ public function testSeekReadsTheInjectedRows(): void $this->addToAssertionCount(1); } - - public function testAcceptsResultsetSubclass(): void - { - $this->assertInstanceOf(Simple::class, $this->mockResultSet([], Simple::class)); - } } diff --git a/tests/Traits/ServicesTraitTest.php b/tests/Traits/ServicesTraitTest.php index 4c73ea1..c9c5e5a 100644 --- a/tests/Traits/ServicesTraitTest.php +++ b/tests/Traits/ServicesTraitTest.php @@ -35,13 +35,18 @@ protected function tearDown(): void parent::tearDown(); } - public function testRedisRoundTrip(): void + public function testDoesNotHaveKeys(): void { - $key = 'talon:test:' . uniqid(); - $this->setRedisKey($key, 'value'); + $this->assertTrue($this->doesNotHaveRedisKey('talon:absent:' . uniqid())); + $this->assertTrue($this->doesNotHaveMemcachedKey('talon_absent_' . uniqid())); + } - $this->assertTrue($this->hasRedisKey($key)); - $this->assertSame('value', $this->getRedisKey($key)); + public function testHasMemcachedKeyWithStoredFalse(): void + { + $key = 'talon_false_' . uniqid(); + $this->setMemcachedKey($key, false); + + $this->assertTrue($this->hasMemcachedKey($key)); } public function testMemcachedRoundTrip(): void @@ -53,18 +58,13 @@ public function testMemcachedRoundTrip(): void $this->assertSame('value', $this->getMemcachedKey($key)); } - public function testHasMemcachedKeyWithStoredFalse(): void + public function testRedisRoundTrip(): void { - $key = 'talon_false_' . uniqid(); - $this->setMemcachedKey($key, false); - - $this->assertTrue($this->hasMemcachedKey($key)); - } + $key = 'talon:test:' . uniqid(); + $this->setRedisKey($key, 'value'); - public function testDoesNotHaveKeys(): void - { - $this->assertTrue($this->doesNotHaveRedisKey('talon:absent:' . uniqid())); - $this->assertTrue($this->doesNotHaveMemcachedKey('talon_absent_' . uniqid())); + $this->assertTrue($this->hasRedisKey($key)); + $this->assertSame('value', $this->getRedisKey($key)); } public function testSendRedisCommand(): void diff --git a/tests/Unit/Bootstrap/RunnerTest.php b/tests/Unit/Bootstrap/RunnerTest.php index d1e5c64..6bd03b3 100644 --- a/tests/Unit/Bootstrap/RunnerTest.php +++ b/tests/Unit/Bootstrap/RunnerTest.php @@ -51,41 +51,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testRunsStagesInOrderWithHooks(): void - { - /** @var ArrayObject $order */ - $order = new ArrayObject(); - $settings = Settings::fromArray(['root' => '/app']); - - $runner = new RecordingRunner($settings, $order); - - $runner - ->before(Stage::Environment, function () use ($order): void { - $order->append('before-env'); - }) - ->after(Stage::Settings, function () use ($order): void { - $order->append('after-settings'); - }); - - $result = $runner->boot(); - - $this->assertSame($settings, $result); - $this->assertSame( - ['before-env', 'env', 'dirs', 'settings', 'after-settings'], - $order->getArrayCopy() - ); - } - - public function testRealRunnerRegistersSettingsIntoTalon(): void - { - Talon::reset(); - $settings = Settings::fromArray(['root' => dirname(__DIR__, 3)]); - - Runner::for($settings)->boot(); - - $this->assertSame($settings, Talon::settings()); - } - public function testBootCreatesTheOutputDirectory(): void { Talon::reset(); @@ -247,4 +212,39 @@ public function testInitEnvironmentTunesXdebugIniWhenLoaded(): void $this->assertSame('4', ini_get('xdebug.var_display_max_depth')); } } + + public function testRealRunnerRegistersSettingsIntoTalon(): void + { + Talon::reset(); + $settings = Settings::fromArray(['root' => dirname(__DIR__, 3)]); + + Runner::for($settings)->boot(); + + $this->assertSame($settings, Talon::settings()); + } + + public function testRunsStagesInOrderWithHooks(): void + { + /** @var ArrayObject $order */ + $order = new ArrayObject(); + $settings = Settings::fromArray(['root' => '/app']); + + $runner = new RecordingRunner($settings, $order); + + $runner + ->before(Stage::Environment, function () use ($order): void { + $order->append('before-env'); + }) + ->after(Stage::Settings, function () use ($order): void { + $order->append('after-settings'); + }); + + $result = $runner->boot(); + + $this->assertSame($settings, $result); + $this->assertSame( + ['before-env', 'env', 'dirs', 'settings', 'after-settings'], + $order->getArrayCopy() + ); + } } diff --git a/tests/Unit/Browser/ClientTest.php b/tests/Unit/Browser/ClientTest.php index d9e1d4f..07a5dd5 100644 --- a/tests/Unit/Browser/ClientTest.php +++ b/tests/Unit/Browser/ClientTest.php @@ -26,38 +26,20 @@ final class ClientTest extends TestCase { - public function testGetRendersContent(): void - { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/form'); - - $this->assertStringContainsString('html()); - } - - public function testPostEchoesParameters(): void - { - $client = $this->client(); - $crawler = $client->request('POST', 'http://localhost/browser/echo', ['q' => 'hi']); - - $this->assertStringContainsString('post:hi', $crawler->text()); - } - - public function testRedirectIsFollowed(): void + public function testAppWithoutGetDiSkipsCookieExtraction(): void { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/bounce'); + $client = new Client(static fn () => new FakeAppWithoutGetDi()); + $client->request('GET', 'http://localhost/'); - $this->assertStringContainsString('landed ok', $crawler->text()); + $this->assertSame([], $client->getCookieJar()->all()); } - public function testSetCookieHeaderLandsInTheJar(): void + public function testBareDiWithoutCookiesServiceSkipsCookieExtraction(): void { - $client = $this->client(); - $client->request('GET', 'http://localhost/browser/cookie'); + $client = new Client(static fn () => new FakeAppWithBareDi()); + $client->request('GET', 'http://localhost/'); - $cookie = $client->getCookieJar()->get('baked'); - $this->assertNotNull($cookie); - $this->assertSame('yummy', $cookie->getValue()); + $this->assertSame([], $client->getCookieJar()->all()); } public function testCookiesServiceCookiesLandInTheJar(): void @@ -70,14 +52,29 @@ public function testCookiesServiceCookiesLandInTheJar(): void $this->assertSame('value', $cookie->getValue()); } - public function testRedirectLoopRaisesInsteadOfRecursing(): void + public function testCookieVariantsSurviveExtraction(): void { - $client = $this->client(); + $client = new Client(static fn () => new FakeAppWithMalformedCookies()); + $client->request('GET', 'http://localhost/'); - $this->expectException(LogicException::class); - $this->expectExceptionMessage('maximum number'); + $jar = $client->getCookieJar(); - $client->request('GET', 'http://localhost/browser/loop'); + // Kills continue->break on the malformed-cookie guards and the + // rawurlencode cast: the int-valued cookie sits after them. + $answer = $jar->get('answer'); + $this->assertNotNull($answer); + $this->assertSame('42', $answer->getValue()); + + // A path-scoped cookie is bucketed under its own path, not '/'. + $this->assertNull($jar->get('scoped')); + $scoped = $jar->get('scoped', '/sub'); + $this->assertNotNull($scoped); + $this->assertSame('v', $scoped->getValue()); + + // Expiration 0 must produce a session cookie without an Expires attribute. + $sess = $jar->get('sess'); + $this->assertNotNull($sess); + $this->assertNull($sess->getExpiresTime()); } public function testFactoryWithoutHandleThrows(): void @@ -88,21 +85,26 @@ public function testFactoryWithoutHandleThrows(): void $client->request('GET', 'http://localhost/'); } - - public function testAppWithoutGetDiSkipsCookieExtraction(): void + public function testGetRendersContent(): void { - $client = new Client(static fn () => new FakeAppWithoutGetDi()); - $client->request('GET', 'http://localhost/'); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/form'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertStringContainsString('html()); } - public function testBareDiWithoutCookiesServiceSkipsCookieExtraction(): void + public function testMalformedCookiesAreSkipped(): void { - $client = new Client(static fn () => new FakeAppWithBareDi()); + $client = new Client(static fn () => new FakeAppWithMalformedCookies()); $client->request('GET', 'http://localhost/'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertNull($client->getCookieJar()->get('malformed')); + $this->assertNull($client->getCookieJar()->get('nonScalar')); + } + + public function testMaxRedirectsIsCapped(): void + { + $this->assertSame(20, $this->client()->getMaxRedirects()); } public function testNonCookiesServiceSkipsCookieExtraction(): void @@ -113,60 +115,47 @@ public function testNonCookiesServiceSkipsCookieExtraction(): void $this->assertSame([], $client->getCookieJar()->all()); } - public function testMalformedCookiesAreSkipped(): void + public function testNonDiContainerSkipsCookieExtraction(): void { - $client = new Client(static fn () => new FakeAppWithMalformedCookies()); + $client = new Client(static fn () => new FakeAppWithNonDiContainer()); $client->request('GET', 'http://localhost/'); - $this->assertNull($client->getCookieJar()->get('malformed')); - $this->assertNull($client->getCookieJar()->get('nonScalar')); + $this->assertSame([], $client->getCookieJar()->all()); } - public function testCookieVariantsSurviveExtraction(): void + public function testPostEchoesParameters(): void { - $client = new Client(static fn () => new FakeAppWithMalformedCookies()); - $client->request('GET', 'http://localhost/'); - - $jar = $client->getCookieJar(); - - // Kills continue->break on the malformed-cookie guards and the - // rawurlencode cast: the int-valued cookie sits after them. - $answer = $jar->get('answer'); - $this->assertNotNull($answer); - $this->assertSame('42', $answer->getValue()); - - // A path-scoped cookie is bucketed under its own path, not '/'. - $this->assertNull($jar->get('scoped')); - $scoped = $jar->get('scoped', '/sub'); - $this->assertNotNull($scoped); - $this->assertSame('v', $scoped->getValue()); + $client = $this->client(); + $crawler = $client->request('POST', 'http://localhost/browser/echo', ['q' => 'hi']); - // Expiration 0 must produce a session cookie without an Expires attribute. - $sess = $jar->get('sess'); - $this->assertNotNull($sess); - $this->assertNull($sess->getExpiresTime()); + $this->assertStringContainsString('post:hi', $crawler->text()); } - public function testMaxRedirectsIsCapped(): void + public function testQueryStringReachesTheApp(): void { - $this->assertSame(20, $this->client()->getMaxRedirects()); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/query?q=needle'); + + $this->assertStringContainsString('uri=/browser/query?q=needle|', $crawler->text()); + $this->assertStringContainsString('|got=needle', $crawler->text()); } - public function testNonDiContainerSkipsCookieExtraction(): void + public function testRedirectIsFollowed(): void { - $client = new Client(static fn () => new FakeAppWithNonDiContainer()); - $client->request('GET', 'http://localhost/'); + $client = $this->client(); + $crawler = $client->request('GET', 'http://localhost/browser/bounce'); - $this->assertSame([], $client->getCookieJar()->all()); + $this->assertStringContainsString('landed ok', $crawler->text()); } - public function testQueryStringReachesTheApp(): void + public function testRedirectLoopRaisesInsteadOfRecursing(): void { - $client = $this->client(); - $crawler = $client->request('GET', 'http://localhost/browser/query?q=needle'); + $client = $this->client(); - $this->assertStringContainsString('uri=/browser/query?q=needle|', $crawler->text()); - $this->assertStringContainsString('|got=needle', $crawler->text()); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('maximum number'); + + $client->request('GET', 'http://localhost/browser/loop'); } public function testRequestWithoutAPathDispatchesTheEmptyPath(): void @@ -187,6 +176,16 @@ public function testResponseDefaultsAreApplied(): void $this->assertSame('text/html; charset=UTF-8', $response->getHeader('Content-Type')); } + public function testSetCookieHeaderLandsInTheJar(): void + { + $client = $this->client(); + $client->request('GET', 'http://localhost/browser/cookie'); + + $cookie = $client->getCookieJar()->get('baked'); + $this->assertNotNull($cookie); + $this->assertSame('yummy', $cookie->getValue()); + } + public function testSetCookieHeaderUsesGmtSuffixedExpires(): void { $client = $this->client(); diff --git a/tests/Unit/Cli/InputTest.php b/tests/Unit/Cli/InputTest.php index 52d190f..272ef5b 100644 --- a/tests/Unit/Cli/InputTest.php +++ b/tests/Unit/Cli/InputTest.php @@ -18,6 +18,13 @@ final class InputTest extends TestCase { + public function testAllowlistedOptionsCombine(): void + { + $input = Input::fromArgv(['talon', '-h', '--version']); + + $this->assertTrue($input->wantsHelp()); + $this->assertTrue($input->wantsVersion()); + } public function testCommandAndArguments(): void { $input = Input::fromArgv(['talon', 'run', 'mysql', 'pgsql']); @@ -29,14 +36,6 @@ public function testCommandAndArguments(): void $this->assertFalse($input->wantsVersion()); } - public function testAllowlistedOptionsCombine(): void - { - $input = Input::fromArgv(['talon', '-h', '--version']); - - $this->assertTrue($input->wantsHelp()); - $this->assertTrue($input->wantsVersion()); - } - public function testDashDashProtectsAllowlistedTokens(): void { // After '--' even talon's own flags forward verbatim. diff --git a/tests/Unit/Cli/SuiteMapTest.php b/tests/Unit/Cli/SuiteMapTest.php index 807ffc0..98439a5 100644 --- a/tests/Unit/Cli/SuiteMapTest.php +++ b/tests/Unit/Cli/SuiteMapTest.php @@ -23,33 +23,6 @@ final class SuiteMapTest extends TestCase { - public function testConfiguredProjectMergesGlobalsIntoSuites(): void - { - $map = new SuiteMap($this->fixture('configured')); - - $unit = $map->resolve('unit'); - $this->assertSame($this->fixture('configured') . '/custom/unit.xml', $unit->config); - $this->assertSame(['extension=fake.so'], $unit->phpFlags); - $this->assertSame(['GLOBAL_ENV' => 'yes', 'SHARED' => 'global'], $unit->env); - $this->assertSame(['--testdox'], $unit->args); - - $db = $map->resolve('db'); - $this->assertSame(['extension=fake.so', 'memory_limit=1G'], $db->phpFlags); - $this->assertSame( - ['GLOBAL_ENV' => 'yes', 'SHARED' => 'suite', 'DB_ONLY' => '1'], - $db->env - ); - $this->assertSame('db', $map->defaultSuite()); - } - - public function testConfigExistenceIsReported(): void - { - $map = new SuiteMap($this->fixture('configured')); - - $this->assertTrue($map->resolve('unit')->configExists()); - $this->assertFalse($map->resolve('db')->configExists()); - } - public function testCastsScalarConfigValuesToStrings(): void { $map = new SuiteMap($this->fixture('casts')); @@ -69,6 +42,32 @@ public function testClashSkipsTheDuplicateButKeepsLaterDiscoveries(): void $this->assertSame(['custom', 'zz'], array_keys($map->suites())); } + public function testConfigExistenceIsReported(): void + { + $map = new SuiteMap($this->fixture('configured')); + + $this->assertTrue($map->resolve('unit')->configExists()); + $this->assertFalse($map->resolve('db')->configExists()); + } + public function testConfiguredProjectMergesGlobalsIntoSuites(): void + { + $map = new SuiteMap($this->fixture('configured')); + + $unit = $map->resolve('unit'); + $this->assertSame($this->fixture('configured') . '/custom/unit.xml', $unit->config); + $this->assertSame(['extension=fake.so'], $unit->phpFlags); + $this->assertSame(['GLOBAL_ENV' => 'yes', 'SHARED' => 'global'], $unit->env); + $this->assertSame(['--testdox'], $unit->args); + + $db = $map->resolve('db'); + $this->assertSame(['extension=fake.so', 'memory_limit=1G'], $db->phpFlags); + $this->assertSame( + ['GLOBAL_ENV' => 'yes', 'SHARED' => 'suite', 'DB_ONLY' => '1'], + $db->env + ); + $this->assertSame('db', $map->defaultSuite()); + } + public function testConventionalProjectDiscoversSuites(): void { $map = new SuiteMap($this->fixture('conventional')); diff --git a/tests/Unit/Database/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php index aecc8ab..e733922 100644 --- a/tests/Unit/Database/ConnectionTest.php +++ b/tests/Unit/Database/ConnectionTest.php @@ -32,6 +32,20 @@ public function testExecuteAndSelect(): void $this->assertSame('john.connor@skynet.dev', $rows[0]['email']); } + public function testInitialQueriesRunAfterConnect(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'db' => ['sqlite' => ['dbname' => ':memory:']], + 'initial_queries' => 'CREATE TABLE seeded (id INTEGER);', + ]); + + $conn = new Connection($settings, 'sqlite'); + $conn->execute('INSERT INTO seeded VALUES (1)'); + + $this->assertCount(1, $conn->select('seeded')); + } + public function testLoadSchemaFromFile(): void { $conn = $this->sqlite(); @@ -95,20 +109,6 @@ public function testSelectReturnsAllMatchingRows(): void $this->assertCount(2, $conn->select('users', ['email' => 'a@b.c'])); } - public function testInitialQueriesRunAfterConnect(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'db' => ['sqlite' => ['dbname' => ':memory:']], - 'initial_queries' => 'CREATE TABLE seeded (id INTEGER);', - ]); - - $conn = new Connection($settings, 'sqlite'); - $conn->execute('INSERT INTO seeded VALUES (1)'); - - $this->assertCount(1, $conn->select('seeded')); - } - public function testSqliteWalPragmaApplied(): void { // WAL mode requires a real file - sqlite silently falls back to diff --git a/tests/Unit/Database/StatementSplitterTest.php b/tests/Unit/Database/StatementSplitterTest.php index 9f5b1e2..cd32684 100644 --- a/tests/Unit/Database/StatementSplitterTest.php +++ b/tests/Unit/Database/StatementSplitterTest.php @@ -18,31 +18,16 @@ final class StatementSplitterTest extends TestCase { - public function testSplitsOnSemicolonAndSkipsComments(): void + public function testBalancedDollarQuotedStatementEndsAtDelimiter(): void { - $sql = "-- a comment\nCREATE TABLE a(id int);\n# another\nINSERT INTO a VALUES (1);\n"; + $sql = "CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$;\nSELECT 2;\n"; $this->assertSame( - ['CREATE TABLE a(id int)', 'INSERT INTO a VALUES (1)'], + ["CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$", 'SELECT 2'], StatementSplitter::split($sql) ); } - public function testKeepsDollarQuotedBlockTogether(): void - { - $sql = "CREATE FUNCTION f() RETURNS int AS \$\$\nBEGIN\nRETURN 1;\nEND;\n\$\$ LANGUAGE plpgsql;\n"; - - $result = StatementSplitter::split($sql); - - $this->assertCount(1, $result); - $this->assertStringContainsString('RETURN 1;', $result[0]); - } - - public function testTrailingStatementWithoutDelimiterIsReturned(): void - { - $this->assertSame(['SELECT 1'], StatementSplitter::split('SELECT 1')); - } - public function testHandlesDelimiterChange(): void { $sql = "DELIMITER ;;\nCREATE A;;\nDELIMITER ;\nSELECT 1;\n"; @@ -50,16 +35,6 @@ public function testHandlesDelimiterChange(): void $this->assertSame(['CREATE A', 'SELECT 1'], StatementSplitter::split($sql)); } - public function testBalancedDollarQuotedStatementEndsAtDelimiter(): void - { - $sql = "CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$;\nSELECT 2;\n"; - - $this->assertSame( - ["CREATE FUNCTION f() AS \$\$\nBEGIN RETURN 1; END;\n\$\$", 'SELECT 2'], - StatementSplitter::split($sql) - ); - } - public function testHyphenAsSecondCharacterIsNotAComment(): void { $this->assertSame(['D-1'], StatementSplitter::split("D-1;\n")); @@ -72,6 +47,16 @@ public function testIndentedCommentLinesAreSkipped(): void $this->assertSame(['SELECT 1'], StatementSplitter::split($sql)); } + public function testKeepsDollarQuotedBlockTogether(): void + { + $sql = "CREATE FUNCTION f() RETURNS int AS \$\$\nBEGIN\nRETURN 1;\nEND;\n\$\$ LANGUAGE plpgsql;\n"; + + $result = StatementSplitter::split($sql); + + $this->assertCount(1, $result); + $this->assertStringContainsString('RETURN 1;', $result[0]); + } + public function testLowercaseDelimiterDirectiveIsHonored(): void { $sql = "delimiter ;;\nCREATE A;;\ndelimiter ;\nSELECT 1;\n"; @@ -83,4 +68,18 @@ public function testMultiLineStatementKeepsLineBreaks(): void { $this->assertSame(["SELECT\n1"], StatementSplitter::split("SELECT\n1;\n")); } + public function testSplitsOnSemicolonAndSkipsComments(): void + { + $sql = "-- a comment\nCREATE TABLE a(id int);\n# another\nINSERT INTO a VALUES (1);\n"; + + $this->assertSame( + ['CREATE TABLE a(id int)', 'INSERT INTO a VALUES (1)'], + StatementSplitter::split($sql) + ); + } + + public function testTrailingStatementWithoutDelimiterIsReturned(): void + { + $this->assertSame(['SELECT 1'], StatementSplitter::split('SELECT 1')); + } } diff --git a/tests/Unit/EnvironmentTest.php b/tests/Unit/EnvironmentTest.php index f3c0379..0368fc9 100644 --- a/tests/Unit/EnvironmentTest.php +++ b/tests/Unit/EnvironmentTest.php @@ -18,17 +18,16 @@ final class EnvironmentTest extends TestCase { - public function testPhalconIsAvailableInTheTestImage(): void - { - $this->assertTrue(Environment::phalconAvailable()); - } - public function testExactlyOneProviderReportsTrue(): void { $this->assertTrue( Environment::viaExtension() || Environment::viaImplementation() ); } + public function testPhalconIsAvailableInTheTestImage(): void + { + $this->assertTrue(Environment::phalconAvailable()); + } public function testProvidersAreMutuallyExclusive(): void { diff --git a/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php b/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php index ad8a15f..519566e 100644 --- a/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractBrowserTestCaseTest.php @@ -19,11 +19,6 @@ final class AbstractBrowserTestCaseTest extends AbstractBrowserTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../../Fakes/Browser/app.php'; - } - public function testSetUpResetsTheDefaultDiAndClearsSession(): void { Di::setDefault(new FactoryDefault()); @@ -34,4 +29,8 @@ public function testSetUpResetsTheDefaultDiAndClearsSession(): void $this->assertNull(Di::getDefault()); $this->assertSame([], $_SESSION); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../../Fakes/Browser/app.php'; + } } diff --git a/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php b/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php index 48fb86e..a0d04eb 100644 --- a/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractFunctionalTestCaseTest.php @@ -17,11 +17,6 @@ final class AbstractFunctionalTestCaseTest extends AbstractFunctionalTestCase { - protected function appFactory(): callable - { - return static fn () => require __DIR__ . '/../../Fakes/App/app.php'; - } - public function testDispatch(): void { $this->dispatch('/test/hello'); @@ -29,4 +24,8 @@ public function testDispatch(): void $this->assertController('test'); $this->assertResponseContentContains('Operator'); } + protected function appFactory(): callable + { + return static fn () => require __DIR__ . '/../../Fakes/App/app.php'; + } } diff --git a/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php b/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php index f2adad7..ef92ade 100644 --- a/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php +++ b/tests/Unit/PHPUnit/AbstractUnitTestCaseTest.php @@ -22,14 +22,6 @@ final class AbstractUnitTestCaseTest extends AbstractUnitTestCase { - public function testInheritsTraitHelpers(): void - { - $this->assertSame('/x/', $this->getDirSeparator('/x')); - - $this->checkPhalconAvailable(); - $this->addToAssertionCount(1); - } - public function testCheckExtensionIsLoadedPassesForLoadedExtension(): void { $this->checkExtensionIsLoaded('json'); @@ -47,30 +39,36 @@ public function testCheckExtensionIsLoadedThrowsForMissingExtension(): void } } - public function testMockWithoutConstructorSkipsConstructor(): void + public function testHelperMethodVisibility(): void { - $subject = $this->mockWithoutConstructor(MockSubject::class); + // Execute each helper so this test covers the mutated method bodies + // (infection only pairs tests with mutants via line coverage). + $this->checkExtensionIsLoaded('json'); + $this->checkPhalconAvailable(); + $this->mockWithConstructor(MockSubject::class, ['custom']); + $this->mockWithoutConstructor(MockSubject::class); - $this->assertInstanceOf(MockSubject::class, $subject); - $this->assertSame('default', $subject->tag); - $this->assertFalse($subject->booted); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkExtensionIsLoaded'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkPhalconAvailable'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithConstructor'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithoutConstructor'))->isPublic()); + $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'phalconAvailable'))->isProtected()); } - - public function testMockWithConstructorRunsConstructor(): void + public function testInheritsTraitHelpers(): void { - $subject = $this->mockWithConstructor(MockSubject::class, ['custom']); + $this->assertSame('/x/', $this->getDirSeparator('/x')); - $this->assertInstanceOf(MockSubject::class, $subject); - $this->assertSame('custom', $subject->tag); - $this->assertTrue($subject->booted); + $this->checkPhalconAvailable(); + $this->addToAssertionCount(1); } - public function testMockWithConstructorStubsMethodsDuringConstruction(): void + public function testMockMethodOverrideAcceptsClosure(): void { - $subject = $this->mockWithConstructor(MockSubject::class, ['custom'], ['boot' => null]); + $subject = $this->mockWithoutConstructor(MockSubject::class, [ + 'greeting' => static fn (): string => 'stubbed', + ]); - $this->assertSame('custom', $subject->tag); - $this->assertFalse($subject->booted); + $this->assertSame('stubbed', $subject->greeting()); } public function testMockMethodOverrideReturnsValue(): void @@ -80,20 +78,58 @@ public function testMockMethodOverrideReturnsValue(): void $this->assertSame(99, $subject->value()); } - public function testMockMethodOverrideAcceptsClosure(): void + public function testMockPropertyOverride(): void + { + $subject = $this->mockWithoutConstructor(MockSubject::class, ['tag' => 'overridden']); + + $this->assertSame('overridden', $subject->tag); + } + + public function testMockStubsMultipleMethodOverrides(): void { $subject = $this->mockWithoutConstructor(MockSubject::class, [ 'greeting' => static fn (): string => 'stubbed', + 'value' => 7, ]); $this->assertSame('stubbed', $subject->greeting()); + $this->assertSame(7, $subject->value()); } - public function testMockPropertyOverride(): void + public function testMockWithConstructorNormalizesCtorArgKeys(): void { - $subject = $this->mockWithoutConstructor(MockSubject::class, ['tag' => 'overridden']); + $plain = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom']); + $this->assertSame('custom', $plain->tag); - $this->assertSame('overridden', $subject->tag); + $stubbed = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom'], ['boot' => null]); + $this->assertSame('custom', $stubbed->tag); + $this->assertFalse($stubbed->booted); + } + + public function testMockWithConstructorRunsConstructor(): void + { + $subject = $this->mockWithConstructor(MockSubject::class, ['custom']); + + $this->assertInstanceOf(MockSubject::class, $subject); + $this->assertSame('custom', $subject->tag); + $this->assertTrue($subject->booted); + } + + public function testMockWithConstructorStubsMethodsDuringConstruction(): void + { + $subject = $this->mockWithConstructor(MockSubject::class, ['custom'], ['boot' => null]); + + $this->assertSame('custom', $subject->tag); + $this->assertFalse($subject->booted); + } + + public function testMockWithoutConstructorSkipsConstructor(): void + { + $subject = $this->mockWithoutConstructor(MockSubject::class); + + $this->assertInstanceOf(MockSubject::class, $subject); + $this->assertSame('default', $subject->tag); + $this->assertFalse($subject->booted); } public function testSetUpResetsTheDefaultDi(): void @@ -131,41 +167,4 @@ protected function phalconAvailable(): bool Di::reset(); } - - public function testHelperMethodVisibility(): void - { - // Execute each helper so this test covers the mutated method bodies - // (infection only pairs tests with mutants via line coverage). - $this->checkExtensionIsLoaded('json'); - $this->checkPhalconAvailable(); - $this->mockWithConstructor(MockSubject::class, ['custom']); - $this->mockWithoutConstructor(MockSubject::class); - - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkExtensionIsLoaded'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'checkPhalconAvailable'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithConstructor'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'mockWithoutConstructor'))->isPublic()); - $this->assertTrue((new ReflectionMethod(AbstractUnitTestCase::class, 'phalconAvailable'))->isProtected()); - } - - public function testMockStubsMultipleMethodOverrides(): void - { - $subject = $this->mockWithoutConstructor(MockSubject::class, [ - 'greeting' => static fn (): string => 'stubbed', - 'value' => 7, - ]); - - $this->assertSame('stubbed', $subject->greeting()); - $this->assertSame(7, $subject->value()); - } - - public function testMockWithConstructorNormalizesCtorArgKeys(): void - { - $plain = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom']); - $this->assertSame('custom', $plain->tag); - - $stubbed = $this->mockWithConstructor(MockSubject::class, ['label' => 'custom'], ['boot' => null]); - $this->assertSame('custom', $stubbed->tag); - $this->assertFalse($stubbed->booted); - } } diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php index edf0555..cd75535 100644 --- a/tests/Unit/SettingsTest.php +++ b/tests/Unit/SettingsTest.php @@ -22,13 +22,15 @@ final class SettingsTest extends TestCase { - public function testFromArrayImplementsContractAndResolvesPaths(): void + public function testDirectoryAccessorsHonorOverrides(): void { - $settings = Settings::fromArray(['root' => '/app']); + $settings = Settings::fromArray([ + 'root' => '/app', + 'paths' => ['output' => 'build/out'], + ]); - $this->assertInstanceOf(SettingsContract::class, $settings); - $this->assertSame('/app', $settings->rootPath()); - $this->assertSame('/app/sub/file.txt', $settings->rootPath('sub/file.txt')); + $this->assertSame('/app/build/out', $settings->outputPath()); + $this->assertSame('/app/tests/_data', $settings->dataPath()); } public function testDirectoryAccessorsUseDefaults(): void @@ -44,97 +46,180 @@ public function testDirectoryAccessorsUseDefaults(): void $this->assertSame('/app/tests/_output/run.log', $settings->outputPath('run.log')); } - public function testDirectoryAccessorsHonorOverrides(): void + public function testDirJoinsOverrideAndRelativeWithoutDuplicateSlashes(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'paths' => ['output' => 'build/out'], + 'paths' => ['output' => 'build/out/'], ]); - $this->assertSame('/app/build/out', $settings->outputPath()); - $this->assertSame('/app/tests/_data', $settings->dataPath()); + $this->assertSame('/app/build/out/run.log', $settings->outputPath('/run.log')); } - public function testNonArrayPathsSectionFallsBackToDefaults(): void + public function testDiscoverRootFallsBackWhenNoComposerJson(): void { - $settings = Settings::fromArray(['root' => '/app', 'paths' => 'not-an-array']); + $cwd = getcwd(); - $this->assertSame('/app/tests/_output', $settings->outputPath()); + try { + chdir('/'); + $this->assertSame('/', Settings::fromEnv()->rootPath()); + } finally { + chdir($cwd ?: '/srv'); + } } - public function testFromEnvDiscoversRootFromComposerJson(): void + public function testDiscoverRootWalksUpToComposerJson(): void { - // The package ships a composer.json at its root; discovery must find it. - $settings = Settings::fromEnv(); + $cwd = getcwd(); - $this->assertFileExists($settings->rootPath('composer.json')); + try { + // A nested directory with no composer.json of its own. + chdir(__DIR__); + $this->assertFileExists(Settings::fromEnv()->rootPath('composer.json')); + } finally { + chdir($cwd ?: '/srv'); + } } - public function testFromArrayRequiresRoot(): void + public function testFromArrayAcceptsServiceOptionsInstanceDirectly(): void { - $this->expectException(InvalidConfiguration::class); - Settings::fromArray([]); + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'redis' => new ServiceOptions('redis', ['host' => 'direct-host']), + ], + ]); + + $this->assertSame(['host' => 'direct-host'], $settings->getServiceOptions('redis')); } - public function testSqliteDsn(): void + public function testFromArrayCastsIntegerServiceKeys(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [0 => ['host' => 'indexed-host']], + ]); + + $this->assertSame(['host' => 'indexed-host'], $settings->getServiceOptions('0')); + } + public function testFromArrayImplementsContractAndResolvesPaths(): void + { + $settings = Settings::fromArray(['root' => '/app']); + + $this->assertInstanceOf(SettingsContract::class, $settings); + $this->assertSame('/app', $settings->rootPath()); + $this->assertSame('/app/sub/file.txt', $settings->rootPath('sub/file.txt')); + } + + public function testFromArrayKeepsAllDbDrivers(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'db' => ['sqlite' => ['dbname' => ':memory:']], + 'db' => [ + 'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'talon'], + 'sqlite' => ['dbname' => '/data/db.sqlite'], + ], ]); - $this->assertSame('sqlite::memory:', $settings->getDatabaseDsn('sqlite')); + $this->assertSame('sqlite:/data/db.sqlite', $settings->getDatabaseDsn('sqlite')); } - public function testMysqlDsnAndOptions(): void + public function testFromArrayKeepsAllServices(): void { - $settings = Settings::fromArray( - [ - 'root' => '/app', - 'db' => [ - 'mysql' => [ - 'host' => '127.0.0.1', - 'port' => 3306, - 'dbname' => 'talon', - 'username' => 'root', - 'password' => 'secret', - 'charset' => 'utf8mb4', - ] - ], - ] - ); + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'first' => ['host' => 'one'], + 'second' => ['host' => 'two'], + ], + ]); - $this->assertSame( - 'mysql:host=127.0.0.1;port=3306;dbname=talon;charset=utf8mb4', - $settings->getDatabaseDsn('mysql') - ); - $this->assertSame('root', $settings->getDatabaseOptions('mysql')['username']); + $this->assertSame(['host' => 'one'], $settings->getServiceOptions('first')); + $this->assertSame(['host' => 'two'], $settings->getServiceOptions('second')); } - public function testUnknownDriverThrows(): void + public function testFromArrayRequiresRoot(): void { - $this->expectException(UnknownDriver::class); - Settings::fromArray(['root' => '/app'])->getDatabaseDsn('oracle'); + $this->expectException(InvalidConfiguration::class); + Settings::fromArray([]); } - public function testFromEnvReadsOverrides(): void + public function testFromEnvBuildsDbServiceOptionsFromOverrides(): void { $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_REDIS_HOST' => 'redis-host', + 'root' => '/app', + 'DATA_MYSQL_HOST' => 'mysql-host', + 'DATA_MYSQL_PORT' => 3307, + 'DATA_MYSQL_NAME' => 'mysql-db', + 'DATA_MYSQL_USER' => 'mysql-user', + 'DATA_MYSQL_PASS' => 'mysql-pass', + 'DATA_MYSQL_CHARSET' => 'utf8', + 'DATA_POSTGRES_HOST' => 'pgsql-host', + 'DATA_POSTGRES_PORT' => '5433', + 'DATA_POSTGRES_NAME' => 'pgsql-db', + 'DATA_POSTGRES_USER' => 'pgsql-user', + 'DATA_POSTGRES_PASS' => 'pgsql-pass', + 'DATA_POSTGRES_SCHEMA' => 'pgsql-schema', + 'DATA_SQLITE_NAME' => '/data/db.sqlite', ]); - $this->assertSame('redis-host', $settings->getServiceOptions('redis')['host']); + $this->assertSame( + [ + 'host' => 'mysql-host', + 'port' => 3307, + 'dbname' => 'mysql-db', + 'username' => 'mysql-user', + 'password' => 'mysql-pass', + 'charset' => 'utf8', + ], + $settings->getServiceOptions('mysql') + ); + $this->assertSame( + [ + 'host' => 'pgsql-host', + 'port' => 5433, + 'dbname' => 'pgsql-db', + 'username' => 'pgsql-user', + 'password' => 'pgsql-pass', + 'schema' => 'pgsql-schema', + ], + $settings->getServiceOptions('pgsql') + ); + $this->assertSame(['dbname' => '/data/db.sqlite'], $settings->getServiceOptions('sqlite')); } - public function testFromEnvReadsMemcachedOverrides(): void + public function testFromEnvDiscoversRootFromComposerJson(): void { - $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_MEMCACHED_HOST' => 'memcached-host', - ]); + // The package ships a composer.json at its root; discovery must find it. + $settings = Settings::fromEnv(); - $this->assertSame('memcached-host', $settings->getServiceOptions('memcached')['host']); + $this->assertFileExists($settings->rootPath('composer.json')); + } + + public function testFromEnvFallsBackToEnvSuperglobal(): void + { + $originalGetenv = getenv('DATA_MYSQL_NAME'); + $originalEnv = $_ENV['DATA_MYSQL_NAME'] ?? null; + + try { + putenv('DATA_MYSQL_NAME'); + $_ENV['DATA_MYSQL_NAME'] = 'env-db'; + + $settings = Settings::fromEnv(['root' => '/app']); + + $this->assertSame('env-db', $settings->getServiceOptions('mysql')['dbname']); + } finally { + putenv( + $originalGetenv === false + ? 'DATA_MYSQL_NAME' + : 'DATA_MYSQL_NAME=' . $originalGetenv + ); + if ($originalEnv === null) { + unset($_ENV['DATA_MYSQL_NAME']); + } else { + $_ENV['DATA_MYSQL_NAME'] = $originalEnv; + } + } } public function testFromEnvReadsBeanstalkServiceOptions(): void @@ -163,6 +248,26 @@ public function testFromEnvReadsDumpFileAndInitialQueries(): void $this->assertSame('SET NAMES utf8;', $settings->get('initial_queries')); } + public function testFromEnvReadsMemcachedOverrides(): void + { + $settings = Settings::fromEnv([ + 'root' => '/app', + 'DATA_MEMCACHED_HOST' => 'memcached-host', + ]); + + $this->assertSame('memcached-host', $settings->getServiceOptions('memcached')['host']); + } + + public function testFromEnvReadsOverrides(): void + { + $settings = Settings::fromEnv([ + 'root' => '/app', + 'DATA_REDIS_HOST' => 'redis-host', + ]); + + $this->assertSame('redis-host', $settings->getServiceOptions('redis')['host']); + } + public function testFromEnvReadsRedisClusterOverrides(): void { $settings = Settings::fromEnv([ @@ -184,6 +289,40 @@ public function testFromEnvRedisClusterHostsEmptyWhenUnset(): void $this->assertSame([], $settings->getServiceOptions('redisCluster')['hosts']); } + public function testFromEnvUsesRootOverride(): void + { + $this->assertSame('/app', Settings::fromEnv(['root' => '/app'])->rootPath()); + } + + public function testGetDatabaseOptionsRejectsUnknownDriver(): void + { + $this->expectException(UnknownDriver::class); + Settings::fromArray(['root' => '/app'])->getDatabaseOptions('oracle'); + } + + public function testGetReturnsExtraConfig(): void + { + $settings = Settings::fromArray(['root' => '/app', 'custom' => 'value']); + + $this->assertSame('value', $settings->get('custom')); + $this->assertSame('fallback', $settings->get('missing', 'fallback')); + } + + public function testGetServiceOptionsFromArray(): void + { + $settings = Settings::fromArray([ + 'root' => '/app', + 'services' => [ + 'beanstalk' => ['host' => '127.0.0.1', 'port' => '11300'], + ], + ]); + + $this->assertSame( + ['host' => '127.0.0.1', 'port' => '11300'], + $settings->getServiceOptions('beanstalk') + ); + } + public function testGetServiceOptionsFromArrayForRedisCluster(): void { $settings = Settings::fromArray([ @@ -202,16 +341,53 @@ public function testGetServiceOptionsFromArrayForRedisCluster(): void ); } - public function testFromArrayAcceptsServiceOptionsInstanceDirectly(): void + public function testGetServiceOptionsUnknownNameReturnsEmptyArray(): void + { + $settings = Settings::fromArray(['root' => '/app']); + + $this->assertSame([], $settings->getServiceOptions('unknown')); + } + + public function testMysqlDsnAndOptions(): void + { + $settings = Settings::fromArray( + [ + 'root' => '/app', + 'db' => [ + 'mysql' => [ + 'host' => '127.0.0.1', + 'port' => 3306, + 'dbname' => 'talon', + 'username' => 'root', + 'password' => 'secret', + 'charset' => 'utf8mb4', + ] + ], + ] + ); + + $this->assertSame( + 'mysql:host=127.0.0.1;port=3306;dbname=talon;charset=utf8mb4', + $settings->getDatabaseDsn('mysql') + ); + $this->assertSame('root', $settings->getDatabaseOptions('mysql')['username']); + } + + public function testNonArrayPathsSectionFallsBackToDefaults(): void + { + $settings = Settings::fromArray(['root' => '/app', 'paths' => 'not-an-array']); + + $this->assertSame('/app/tests/_output', $settings->outputPath()); + } + + public function testNonArraySectionIsIgnored(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'services' => [ - 'redis' => new ServiceOptions('redis', ['host' => 'direct-host']), - ], + 'services' => ['redis' => 'not-an-array'], ]); - $this->assertSame(['host' => 'direct-host'], $settings->getServiceOptions('redis')); + $this->assertSame([], $settings->getServiceOptions('redis')); } public function testPgsqlDsn(): void @@ -254,204 +430,27 @@ public function testPgsqlOptionsIncludeSchema(): void $this->assertSame('public', $settings->getDatabaseOptions('pgsql')['schema']); } - public function testGetReturnsExtraConfig(): void - { - $settings = Settings::fromArray(['root' => '/app', 'custom' => 'value']); - - $this->assertSame('value', $settings->get('custom')); - $this->assertSame('fallback', $settings->get('missing', 'fallback')); - } - - public function testGetServiceOptionsFromArray(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [ - 'beanstalk' => ['host' => '127.0.0.1', 'port' => '11300'], - ], - ]); - - $this->assertSame( - ['host' => '127.0.0.1', 'port' => '11300'], - $settings->getServiceOptions('beanstalk') - ); - } - - public function testGetServiceOptionsUnknownNameReturnsEmptyArray(): void - { - $settings = Settings::fromArray(['root' => '/app']); - - $this->assertSame([], $settings->getServiceOptions('unknown')); - } - - public function testDiscoverRootFallsBackWhenNoComposerJson(): void - { - $cwd = getcwd(); - - try { - chdir('/'); - $this->assertSame('/', Settings::fromEnv()->rootPath()); - } finally { - chdir($cwd ?: '/srv'); - } - } - - public function testDiscoverRootWalksUpToComposerJson(): void - { - $cwd = getcwd(); - - try { - // A nested directory with no composer.json of its own. - chdir(__DIR__); - $this->assertFileExists(Settings::fromEnv()->rootPath('composer.json')); - } finally { - chdir($cwd ?: '/srv'); - } - } - - public function testNonArraySectionIsIgnored(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => ['redis' => 'not-an-array'], - ]); - - $this->assertSame([], $settings->getServiceOptions('redis')); - } - - public function testDirJoinsOverrideAndRelativeWithoutDuplicateSlashes(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'paths' => ['output' => 'build/out/'], - ]); - - $this->assertSame('/app/build/out/run.log', $settings->outputPath('/run.log')); - } - - public function testFromArrayCastsIntegerServiceKeys(): void + public function testRootPathTrimsSlashes(): void { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [0 => ['host' => 'indexed-host']], - ]); + $settings = Settings::fromArray(['root' => '/app/']); - $this->assertSame(['host' => 'indexed-host'], $settings->getServiceOptions('0')); + $this->assertSame('/app', $settings->rootPath()); + $this->assertSame('/app/sub', $settings->rootPath('/sub')); } - public function testFromArrayKeepsAllDbDrivers(): void + public function testSqliteDsn(): void { $settings = Settings::fromArray([ 'root' => '/app', - 'db' => [ - 'mysql' => ['host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'talon'], - 'sqlite' => ['dbname' => '/data/db.sqlite'], - ], - ]); - - $this->assertSame('sqlite:/data/db.sqlite', $settings->getDatabaseDsn('sqlite')); - } - - public function testFromArrayKeepsAllServices(): void - { - $settings = Settings::fromArray([ - 'root' => '/app', - 'services' => [ - 'first' => ['host' => 'one'], - 'second' => ['host' => 'two'], - ], - ]); - - $this->assertSame(['host' => 'one'], $settings->getServiceOptions('first')); - $this->assertSame(['host' => 'two'], $settings->getServiceOptions('second')); - } - - public function testFromEnvBuildsDbServiceOptionsFromOverrides(): void - { - $settings = Settings::fromEnv([ - 'root' => '/app', - 'DATA_MYSQL_HOST' => 'mysql-host', - 'DATA_MYSQL_PORT' => 3307, - 'DATA_MYSQL_NAME' => 'mysql-db', - 'DATA_MYSQL_USER' => 'mysql-user', - 'DATA_MYSQL_PASS' => 'mysql-pass', - 'DATA_MYSQL_CHARSET' => 'utf8', - 'DATA_POSTGRES_HOST' => 'pgsql-host', - 'DATA_POSTGRES_PORT' => '5433', - 'DATA_POSTGRES_NAME' => 'pgsql-db', - 'DATA_POSTGRES_USER' => 'pgsql-user', - 'DATA_POSTGRES_PASS' => 'pgsql-pass', - 'DATA_POSTGRES_SCHEMA' => 'pgsql-schema', - 'DATA_SQLITE_NAME' => '/data/db.sqlite', + 'db' => ['sqlite' => ['dbname' => ':memory:']], ]); - $this->assertSame( - [ - 'host' => 'mysql-host', - 'port' => 3307, - 'dbname' => 'mysql-db', - 'username' => 'mysql-user', - 'password' => 'mysql-pass', - 'charset' => 'utf8', - ], - $settings->getServiceOptions('mysql') - ); - $this->assertSame( - [ - 'host' => 'pgsql-host', - 'port' => 5433, - 'dbname' => 'pgsql-db', - 'username' => 'pgsql-user', - 'password' => 'pgsql-pass', - 'schema' => 'pgsql-schema', - ], - $settings->getServiceOptions('pgsql') - ); - $this->assertSame(['dbname' => '/data/db.sqlite'], $settings->getServiceOptions('sqlite')); - } - - public function testFromEnvFallsBackToEnvSuperglobal(): void - { - $originalGetenv = getenv('DATA_MYSQL_NAME'); - $originalEnv = $_ENV['DATA_MYSQL_NAME'] ?? null; - - try { - putenv('DATA_MYSQL_NAME'); - $_ENV['DATA_MYSQL_NAME'] = 'env-db'; - - $settings = Settings::fromEnv(['root' => '/app']); - - $this->assertSame('env-db', $settings->getServiceOptions('mysql')['dbname']); - } finally { - putenv( - $originalGetenv === false - ? 'DATA_MYSQL_NAME' - : 'DATA_MYSQL_NAME=' . $originalGetenv - ); - if ($originalEnv === null) { - unset($_ENV['DATA_MYSQL_NAME']); - } else { - $_ENV['DATA_MYSQL_NAME'] = $originalEnv; - } - } - } - - public function testFromEnvUsesRootOverride(): void - { - $this->assertSame('/app', Settings::fromEnv(['root' => '/app'])->rootPath()); + $this->assertSame('sqlite::memory:', $settings->getDatabaseDsn('sqlite')); } - public function testGetDatabaseOptionsRejectsUnknownDriver(): void + public function testUnknownDriverThrows(): void { $this->expectException(UnknownDriver::class); - Settings::fromArray(['root' => '/app'])->getDatabaseOptions('oracle'); - } - - public function testRootPathTrimsSlashes(): void - { - $settings = Settings::fromArray(['root' => '/app/']); - - $this->assertSame('/app', $settings->rootPath()); - $this->assertSame('/app/sub', $settings->rootPath('/sub')); + Settings::fromArray(['root' => '/app'])->getDatabaseDsn('oracle'); } } diff --git a/tests/Unit/TalonTest.php b/tests/Unit/TalonTest.php index 8c6cd52..a27e44a 100644 --- a/tests/Unit/TalonTest.php +++ b/tests/Unit/TalonTest.php @@ -26,20 +26,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testSettingsLazilyFallsBackToEnv(): void - { - Talon::reset(); - $this->assertInstanceOf(SettingsContract::class, Talon::settings()); - } - - public function testUseSettingsRegistersTheSlot(): void - { - $settings = Settings::fromArray(['root' => '/app']); - Talon::useSettings($settings); - - $this->assertSame($settings, Talon::settings()); - } - public function testBootReturnsAndRegistersSettings(): void { $settings = Settings::fromArray(['root' => dirname(__DIR__, 2)]); @@ -56,4 +42,18 @@ public function testResetClearsTheSlot(): void $this->assertNotSame($settings, Talon::settings()); } + + public function testSettingsLazilyFallsBackToEnv(): void + { + Talon::reset(); + $this->assertInstanceOf(SettingsContract::class, Talon::settings()); + } + + public function testUseSettingsRegistersTheSlot(): void + { + $settings = Settings::fromArray(['root' => '/app']); + Talon::useSettings($settings); + + $this->assertSame($settings, Talon::settings()); + } } From 92ee0b2dd6aa7a81b0ad6938d1510cd775ee5d49 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:51:50 -0500 Subject: [PATCH 03/21] [#19] - adding TALON_REST_URL to Settings The REST surface needs a base URL. It goes in the extra bag rather than services because the fromEnv() service table is host/port-shaped and a URL does not fit it. CODECEPTION_URL/CODECEPTION_PORT are deliberately not carried over. --- src/Settings.php | 1 + tests/Unit/SettingsTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/Settings.php b/src/Settings.php index 3cd6184..995981c 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -168,6 +168,7 @@ public static function fromEnv(array $overrides = []): self [ 'dump_file' => $env('dump_file'), 'initial_queries' => $env('initial_queries'), + 'rest_url' => $env('TALON_REST_URL', 'http://127.0.0.1:8080'), ], [], $services, diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php index cd75535..709170a 100644 --- a/tests/Unit/SettingsTest.php +++ b/tests/Unit/SettingsTest.php @@ -289,6 +289,23 @@ public function testFromEnvRedisClusterHostsEmptyWhenUnset(): void $this->assertSame([], $settings->getServiceOptions('redisCluster')['hosts']); } + public function testFromEnvRestUrlDefault(): void + { + $settings = Settings::fromEnv(['root' => '/app']); + + $this->assertSame('http://127.0.0.1:8080', $settings->get('rest_url')); + } + + public function testFromEnvRestUrlOverride(): void + { + $settings = Settings::fromEnv([ + 'root' => '/app', + 'TALON_REST_URL' => 'http://api.test:9501', + ]); + + $this->assertSame('http://api.test:9501', $settings->get('rest_url')); + } + public function testFromEnvUsesRootOverride(): void { $this->assertSame('/app', Settings::fromEnv(['root' => '/app'])->rootPath()); From 210ddfb2e3c3f27bdbff23bdab3d25348780abc1 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:51:56 -0500 Subject: [PATCH 04/21] [#19] - adding symfony/http-client and symfony/mime Both are needed by Symfony\Component\BrowserKit\HttpBrowser, which the REST surface uses to make real requests. browser-kit lists them as require-dev, not require, so neither arrives transitively. Mime is not only for file uploads: HttpBrowser::getBodyAndExtraHeaders() raises 'You cannot pass non-empty bodies as the Mime component is not installed' for every method other than GET/HEAD, and it checks that before it looks at the body at all - so even a PUT with no parameters fails without it. Constrained to ^6.4 || ^7.0 to match browser-kit and dom-crawler. Symfony 7.x requires PHP 8.2, so on our 8.1 floor composer resolves the 6.4 LTS line. psr/container, psr/log, deprecation-contracts and service-contracts move from packages-dev to packages at unchanged versions, because http-client requires them at runtime. --- composer.json | 4 +- composer.lock | 1292 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 867 insertions(+), 429 deletions(-) diff --git a/composer.json b/composer.json index ffad2a2..aae95eb 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,9 @@ "php": "^8.1", "phalcon/cli-options-parser": "^2.0", "symfony/browser-kit": "^6.4 || ^7.0", - "symfony/dom-crawler": "^6.4 || ^7.0" + "symfony/dom-crawler": "^6.4 || ^7.0", + "symfony/http-client": "^6.4 || ^7.0", + "symfony/mime": "^6.4 || ^7.0" }, "require-dev": { "ext-pdo": "*", diff --git a/composer.lock b/composer.lock index af3b9b1..328df99 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4a97efb596a2e37bbed760a3e42f816", + "content-hash": "1bd51af2c37305704eef87a5db18b8c8", "packages": [ { "name": "masterminds/html5", @@ -153,6 +153,109 @@ ], "time": "2023-11-24T16:04:00+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "symfony/browser-kit", "version": "v6.4.42", @@ -223,39 +326,712 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-06-08T07:06:12+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v6.4.40", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", + "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.40" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-19T20:33:22+00:00" + }, + { + "name": "symfony/http-client", + "version": "v6.4.42", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/b71b312ca0f211fbb19a20a51bde50fbefb66a99", + "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.4.42" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-12T09:42:32+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/mime", + "version": "v6.4.41", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/5575d37f8841e4e31d5df79ab3db078ae557ff8e", + "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<5.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.4|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v6.4.41" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T14:40:34+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "symfony/dom-crawler", - "version": "v6.4.40", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", - "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -263,18 +1039,25 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Eases DOM navigation for HTML and XML documents", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.40" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -294,31 +1077,25 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.37.0", + "name": "symfony/polyfill-php83", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { "php": ">=7.2" }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -331,8 +1108,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -340,24 +1120,24 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "ctype", "polyfill", - "portable" + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -377,46 +1157,47 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "ext-mbstring": "For best performance" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -432,17 +1213,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -462,7 +1244,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-06-16T09:55:08+00:00" } ], "packages-dev": [ @@ -2749,59 +3531,6 @@ ], "time": "2026-06-11T16:56:53+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, { "name": "psr/event-dispatcher", "version": "1.0.0", @@ -2890,70 +3619,20 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", "keywords": [ - "log", + "http", + "http-message", "psr", - "psr-3" + "psr-7", + "request", + "response" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "react/cache", @@ -4740,77 +5419,6 @@ ], "time": "2026-06-15T05:35:29+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T06:23:12+00:00" - }, { "name": "symfony/event-dispatcher", "version": "v6.4.37", @@ -5266,91 +5874,6 @@ ], "time": "2026-05-26T05:58:03+00:00" }, - { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-25T13:48:31+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.37.0", @@ -5660,93 +6183,6 @@ ], "time": "2026-05-23T13:47:21+00:00" }, - { - "name": "symfony/service-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-16T09:55:08+00:00" - }, { "name": "symfony/stopwatch", "version": "v6.4.24", From 771caeee6ec08759dcd694d14955503a179b88b0 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:05 -0500 Subject: [PATCH 05/21] [#19] - adding Http\HttpCode with standard reason phrases getDescription() returns the '404 (Not Found)' form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test: rest-api emits that exact string in its JSON error payload from its own table, so an assertion comparing its output against its own source would assert nothing. --- src/Http/HttpCode.php | 89 +++++++++++++++++++++++ tests/Unit/Http/HttpCodeTest.php | 117 +++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/Http/HttpCode.php create mode 100644 tests/Unit/Http/HttpCodeTest.php diff --git a/src/Http/HttpCode.php b/src/Http/HttpCode.php new file mode 100644 index 0000000..8ff94c4 --- /dev/null +++ b/src/Http/HttpCode.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function sprintf; + +/** + * HTTP status codes and their standard reason phrases. + * + * getDescription() is deliberately an independent implementation of the + * ' ()' format rather than a lookup into the application under + * test - an application asserting its own emitted string against its own table + * would assert nothing. + */ +final class HttpCode +{ + public const ACCEPTED = 202; + public const BAD_GATEWAY = 502; + public const BAD_REQUEST = 400; + public const CONFLICT = 409; + public const CREATED = 201; + public const FORBIDDEN = 403; + public const FOUND = 302; + public const GATEWAY_TIMEOUT = 504; + public const GONE = 410; + public const INTERNAL_SERVER_ERROR = 500; + public const METHOD_NOT_ALLOWED = 405; + public const MOVED_PERMANENTLY = 301; + public const NO_CONTENT = 204; + public const NOT_ACCEPTABLE = 406; + public const NOT_FOUND = 404; + public const NOT_IMPLEMENTED = 501; + public const NOT_MODIFIED = 304; + public const OK = 200; + public const PERMANENT_REDIRECT = 308; + public const SEE_OTHER = 303; + public const SERVICE_UNAVAILABLE = 503; + public const TEMPORARY_REDIRECT = 307; + public const TOO_MANY_REQUESTS = 429; + public const UNAUTHORIZED = 401; + public const UNPROCESSABLE_ENTITY = 422; + public const UNSUPPORTED_MEDIA_TYPE = 415; + + /** @var array */ + private const PHRASES = [ + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 204 => 'No Content', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 409 => 'Conflict', + 410 => 'Gone', + 415 => 'Unsupported Media Type', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + ]; + + public static function getDescription(int $code): string + { + return sprintf('%d (%s)', $code, self::PHRASES[$code] ?? 'Unknown'); + } +} diff --git a/tests/Unit/Http/HttpCodeTest.php b/tests/Unit/Http/HttpCodeTest.php new file mode 100644 index 0000000..728f8b3 --- /dev/null +++ b/tests/Unit/Http/HttpCodeTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\HttpCode; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +use function constant; + +final class HttpCodeTest extends AbstractUnitTestCase +{ + /** + * @return array + */ + public static function providerConstants(): array + { + return [ + ['ACCEPTED', 202], + ['BAD_GATEWAY', 502], + ['BAD_REQUEST', 400], + ['CONFLICT', 409], + ['CREATED', 201], + ['FORBIDDEN', 403], + ['FOUND', 302], + ['GATEWAY_TIMEOUT', 504], + ['GONE', 410], + ['INTERNAL_SERVER_ERROR', 500], + ['METHOD_NOT_ALLOWED', 405], + ['MOVED_PERMANENTLY', 301], + ['NOT_ACCEPTABLE', 406], + ['NOT_FOUND', 404], + ['NOT_IMPLEMENTED', 501], + ['NOT_MODIFIED', 304], + ['NO_CONTENT', 204], + ['OK', 200], + ['PERMANENT_REDIRECT', 308], + ['SEE_OTHER', 303], + ['SERVICE_UNAVAILABLE', 503], + ['TEMPORARY_REDIRECT', 307], + ['TOO_MANY_REQUESTS', 429], + ['UNAUTHORIZED', 401], + ['UNPROCESSABLE_ENTITY', 422], + ['UNSUPPORTED_MEDIA_TYPE', 415], + ]; + } + + /** + * @return array + */ + public static function providerDescriptions(): array + { + return [ + [200, '200 (OK)'], + [201, '201 (Created)'], + [202, '202 (Accepted)'], + [204, '204 (No Content)'], + [301, '301 (Moved Permanently)'], + [302, '302 (Found)'], + [303, '303 (See Other)'], + [304, '304 (Not Modified)'], + [307, '307 (Temporary Redirect)'], + [308, '308 (Permanent Redirect)'], + [400, '400 (Bad Request)'], + [401, '401 (Unauthorized)'], + [403, '403 (Forbidden)'], + [404, '404 (Not Found)'], + [405, '405 (Method Not Allowed)'], + [406, '406 (Not Acceptable)'], + [409, '409 (Conflict)'], + [410, '410 (Gone)'], + [415, '415 (Unsupported Media Type)'], + [422, '422 (Unprocessable Entity)'], + [429, '429 (Too Many Requests)'], + [500, '500 (Internal Server Error)'], + [501, '501 (Not Implemented)'], + [502, '502 (Bad Gateway)'], + [503, '503 (Service Unavailable)'], + [504, '504 (Gateway Timeout)'], + ]; + } + + /** + * Every constant must resolve to the code it names, and every constant must + * have a phrase - a constant without one would silently report 'Unknown'. + * + * @dataProvider providerConstants + */ + public function testConstantResolvesToItsCodeAndHasAPhrase(string $name, int $code): void + { + $this->assertSame($code, constant(HttpCode::class . '::' . $name)); + $this->assertStringNotContainsString('Unknown', HttpCode::getDescription($code)); + } + + public function testGetDescriptionForUnknownCode(): void + { + $this->assertSame('599 (Unknown)', HttpCode::getDescription(599)); + } + + /** + * @dataProvider providerDescriptions + */ + public function testGetDescriptionReturnsCodeAndPhrase(int $code, string $expected): void + { + $this->assertSame($expected, HttpCode::getDescription($code)); + } +} From 8705d0117f1b8ac7c1df664bcaf8be6077c0a971 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:06 -0500 Subject: [PATCH 06/21] [#19] - adding Http\JsonSubset for recursive JSON fragment matching Keys and list elements present in the response but absent from the expectation are ignored, so a fragment can be asserted against a full JSON:API envelope. List elements match in any order; scalars compare strictly. --- src/Http/JsonSubset.php | 75 ++++++++++++++++++++ tests/Unit/Http/JsonSubsetTest.php | 106 +++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/Http/JsonSubset.php create mode 100644 tests/Unit/Http/JsonSubsetTest.php diff --git a/src/Http/JsonSubset.php b/src/Http/JsonSubset.php new file mode 100644 index 0000000..0f909f4 --- /dev/null +++ b/src/Http/JsonSubset.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function array_is_list; +use function array_key_exists; +use function is_array; + +/** + * Recursive subset matching for JSON documents. + * + * Keys and list elements present in the actual document but absent from the + * expected one are ignored, so a fragment can be asserted against a full + * envelope. List elements match in any order. + */ +final class JsonSubset +{ + public static function contains(mixed $actual, mixed $expected): bool + { + if (!is_array($expected)) { + return $actual === $expected; + } + + if (!is_array($actual)) { + return false; + } + + if (array_is_list($expected)) { + return self::containsList($actual, $expected); + } + + foreach ($expected as $key => $value) { + if (!array_key_exists($key, $actual) || !self::contains($actual[$key], $value)) { + return false; + } + } + + return true; + } + + /** + * @param array $actual + * @param array $expected + */ + private static function containsList(array $actual, array $expected): bool + { + foreach ($expected as $item) { + $found = false; + foreach ($actual as $candidate) { + if (self::contains($candidate, $item)) { + $found = true; + + break; + } + } + + if (!$found) { + return false; + } + } + + return true; + } +} diff --git a/tests/Unit/Http/JsonSubsetTest.php b/tests/Unit/Http/JsonSubsetTest.php new file mode 100644 index 0000000..91c2737 --- /dev/null +++ b/tests/Unit/Http/JsonSubsetTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\JsonSubset; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class JsonSubsetTest extends AbstractUnitTestCase +{ + public function testEmptyExpectedListAgainstEmptyActualList(): void + { + $this->assertTrue(JsonSubset::contains(['data' => []], ['data' => []])); + } + + public function testEmptyExpectedMatchesAnything(): void + { + $this->assertTrue(JsonSubset::contains(['a' => 1], [])); + } + + public function testExpectedArrayAgainstScalarActualFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 'scalar'], ['a' => ['b' => 1]])); + } + + public function testExpectedListAgainstEmptyActualListFails(): void + { + $this->assertFalse(JsonSubset::contains(['data' => []], ['data' => [['id' => 1]]])); + } + + public function testListElementMatchesOutOfOrder(): void + { + $actual = ['data' => [['id' => 1], ['id' => 2], ['id' => 3]]]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['id' => 3], ['id' => 1]]])); + } + + public function testListElementNotPresentFails(): void + { + $actual = ['data' => [['id' => 1]]]; + + $this->assertFalse(JsonSubset::contains($actual, ['data' => [['id' => 9]]])); + } + + public function testMatchesFragmentOfLargerDocument(): void + { + $actual = [ + 'jsonapi' => ['version' => '1.0'], + 'data' => [['id' => 1, 'name' => 'Acme']], + 'meta' => ['timestamp' => '2026-07-15T00:00:00+00:00'], + ]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['name' => 'Acme']]])); + $this->assertTrue(JsonSubset::contains($actual, ['jsonapi' => ['version' => '1.0']])); + } + + public function testMismatchedScalarFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['a' => 2])); + } + + public function testMissingKeyFails(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['b' => 1])); + } + + public function testNestedListInsideMapInsideList(): void + { + $actual = [ + 'data' => [ + ['id' => 1, 'tags' => ['a', 'b']], + ['id' => 2, 'tags' => ['c']], + ], + ]; + + $this->assertTrue(JsonSubset::contains($actual, ['data' => [['tags' => ['b']]]])); + $this->assertFalse(JsonSubset::contains($actual, ['data' => [['tags' => ['z']]]])); + } + + public function testNullExpectedMatchesNullActual(): void + { + $this->assertTrue(JsonSubset::contains(['a' => null], ['a' => null])); + $this->assertFalse(JsonSubset::contains(['a' => 0], ['a' => null])); + } + + public function testScalarComparisonIsStrict(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], ['a' => '1'])); + } + + public function testTopLevelScalarComparison(): void + { + $this->assertTrue(JsonSubset::contains('same', 'same')); + $this->assertFalse(JsonSubset::contains('one', 'other')); + } +} From 5da0bcb750589552bcf308397361c8e6a31b095e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:06 -0500 Subject: [PATCH 07/21] [#19] - adding Http\JsonType type-map validation Validates a decoded document against a map of types rather than values: string, integer, float, boolean, array, null, the :date filter, | unions, and nested maps. Keys absent from the map are ignored - the envelope check names only jsonapi and meta while the document also carries data. Types are strict, so an int does not satisfy float. Scoped to what is needed plus the obvious neighbours; Codeception's JsonType carries a broader DSL that is not worth cloning. --- src/Http/JsonType.php | 124 +++++++++++++++++++++++++ tests/Unit/Http/JsonTypeTest.php | 151 +++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 src/Http/JsonType.php create mode 100644 tests/Unit/Http/JsonTypeTest.php diff --git a/src/Http/JsonType.php b/src/Http/JsonType.php new file mode 100644 index 0000000..1514f03 --- /dev/null +++ b/src/Http/JsonType.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Http; + +use function array_key_exists; +use function explode; +use function get_debug_type; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; +use function sprintf; +use function str_contains; +use function strtotime; + +/** + * Validates a decoded JSON document against a map of type expectations. + * + * Keys absent from the type map are ignored, so a map can describe the part of + * an envelope a test cares about while the document carries more. Types are + * strict: an int does not satisfy 'float'. + * + * Supported specs: 'array', 'boolean', 'float', 'integer', 'null', 'string', + * optionally suffixed with ':date', and joined with '|' to form a union. + */ +final class JsonType +{ + /** + * @param array $types + * + * @return string|null null when the document matches, otherwise the reason + */ + public static function match(mixed $actual, array $types, string $path = ''): ?string + { + if (!is_array($actual)) { + return sprintf("Key '%s' expected an object, got '%s'", $path, get_debug_type($actual)); + } + + foreach ($types as $key => $expected) { + $current = '' === $path ? (string) $key : $path . '.' . $key; + + if (!array_key_exists($key, $actual)) { + return sprintf("Key '%s' is missing", $current); + } + + $value = $actual[$key]; + + if (is_array($expected)) { + $error = self::match($value, $expected, $current); + if (null !== $error) { + return $error; + } + + continue; + } + + if (!is_string($expected) || !self::matchesSpec($value, $expected)) { + return sprintf( + "Key '%s' expected '%s', got '%s'", + $current, + is_string($expected) ? $expected : get_debug_type($expected), + get_debug_type($value) + ); + } + } + + return null; + } + + private static function matchesFilter(mixed $value, string $filter): bool + { + return match ($filter) { + 'date' => is_string($value) && false !== strtotime($value), + default => false, + }; + } + + private static function matchesSingle(mixed $value, string $alternative): bool + { + $filter = null; + if (str_contains($alternative, ':')) { + [$alternative, $filter] = explode(':', $alternative, 2); + } + + $matches = match ($alternative) { + 'array' => is_array($value), + 'boolean' => is_bool($value), + 'float' => is_float($value), + 'integer' => is_int($value), + 'null' => null === $value, + 'string' => is_string($value), + default => false, + }; + + if (!$matches) { + return false; + } + + return null === $filter || self::matchesFilter($value, $filter); + } + + private static function matchesSpec(mixed $value, string $spec): bool + { + foreach (explode('|', $spec) as $alternative) { + if (self::matchesSingle($value, $alternative)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/Http/JsonTypeTest.php b/tests/Unit/Http/JsonTypeTest.php new file mode 100644 index 0000000..b8a24a6 --- /dev/null +++ b/tests/Unit/Http/JsonTypeTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\Http; + +use Phalcon\Talon\Http\JsonType; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class JsonTypeTest extends AbstractUnitTestCase +{ + public function testDateFilterAcceptsIsoDate(): void + { + $this->assertNull(JsonType::match(['t' => '2026-07-15T10:30:00+00:00'], ['t' => 'string:date'])); + } + + public function testDateFilterRejectsNonDateString(): void + { + $this->assertSame( + "Key 't' expected 'string:date', got 'string'", + JsonType::match(['t' => 'not a date'], ['t' => 'string:date']) + ); + } + + public function testEmptyTypeMapMatchesAnything(): void + { + $this->assertNull(JsonType::match(['a' => 1], [])); + } + + public function testIntegerIsNotAFloat(): void + { + $this->assertNotNull(JsonType::match(['a' => 1], ['a' => 'float'])); + $this->assertNull(JsonType::match(['a' => 1.5], ['a' => 'float'])); + } + + public function testMatchesTheRestApiEnvelope(): void + { + $actual = [ + 'jsonapi' => ['version' => '1.0'], + 'data' => [['id' => 1]], + 'meta' => [ + 'timestamp' => '2026-07-15T10:30:00+00:00', + 'hash' => 'a1b2c3', + ], + ]; + + $result = JsonType::match( + $actual, + [ + 'jsonapi' => ['version' => 'string'], + 'meta' => [ + 'timestamp' => 'string:date', + 'hash' => 'string', + ], + ] + ); + + $this->assertNull($result); + } + + public function testMissingKeyFails(): void + { + $this->assertSame("Key 'b' is missing", JsonType::match(['a' => 1], ['b' => 'integer'])); + } + + public function testMissingNestedKeyReportsPath(): void + { + $this->assertSame( + "Key 'meta.hash' is missing", + JsonType::match(['meta' => ['timestamp' => 'x']], ['meta' => ['hash' => 'string']]) + ); + } + + public function testNestedMapAgainstNonArrayFails(): void + { + $this->assertSame( + "Key 'meta' expected an object, got 'string'", + JsonType::match(['meta' => 'scalar'], ['meta' => ['hash' => 'string']]) + ); + } + + public function testNonStringNonArraySpecFails(): void + { + $this->assertSame( + "Key 'a' expected 'int', got 'int'", + JsonType::match(['a' => 1], ['a' => 123]) + ); + } + + public function testScalarTypes(): void + { + $this->assertNull(JsonType::match(['a' => true], ['a' => 'boolean'])); + $this->assertNull(JsonType::match(['a' => [1, 2]], ['a' => 'array'])); + $this->assertNull(JsonType::match(['a' => null], ['a' => 'null'])); + $this->assertNull(JsonType::match(['a' => 'x'], ['a' => 'string'])); + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'integer'])); + } + + public function testTopLevelNonArrayFails(): void + { + $this->assertSame( + "Key '' expected an object, got 'string'", + JsonType::match('scalar', ['a' => 'string']) + ); + } + + public function testUnionAcceptsEitherAlternative(): void + { + $this->assertNull(JsonType::match(['a' => null], ['a' => 'string|null'])); + $this->assertNull(JsonType::match(['a' => 'x'], ['a' => 'string|null'])); + $this->assertNotNull(JsonType::match(['a' => 1], ['a' => 'string|null'])); + } + + public function testUnknownBaseTypeNeverMatches(): void + { + $this->assertSame( + "Key 'a' expected 'bogus', got 'int'", + JsonType::match(['a' => 1], ['a' => 'bogus']) + ); + } + + public function testUnknownFilterNeverMatches(): void + { + $this->assertSame( + "Key 't' expected 'string:bogus', got 'string'", + JsonType::match(['t' => 'anything'], ['t' => 'string:bogus']) + ); + } + + public function testUnlistedKeysAreIgnored(): void + { + $this->assertNull(JsonType::match(['a' => 1, 'b' => 'x'], ['a' => 'integer'])); + } + + public function testWrongTypeReportsPath(): void + { + $this->assertSame( + "Key 'meta.hash' expected 'string', got 'int'", + JsonType::match(['meta' => ['hash' => 1]], ['meta' => ['hash' => 'string']]) + ); + } +} From bee7f74818d8c68c77501e45863c3e4897853b2e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:13 -0500 Subject: [PATCH 08/21] [#19] - adding RestTrait with HTTP transport, headers and verbs Drives a BrowserKit HttpBrowser over symfony/http-client, so requests cross a real HTTP boundary. The in-process Browser\Client cannot serve here: it never maps request headers into $_SERVER (rest-api reads its JWT via getHeader('Authorization')), and a raw JSON body cannot reach php://input in-process. restHttpClient() is the transport seam. It returns null so HttpBrowser builds a real client; a test overrides it with a MockHttpClient and drives the same request-building path without a live server. Request headers are tracked in the trait and re-pushed through setServerParameters() on every change, because BrowserKit has no removeServerParameter() - rebuilding the whole set is the only way to drop one, which unsetHttpHeader() needs. $files is not carried over from Codeception's signature: rest-api uploads nothing and no use case needs it yet. --- src/Traits/RestTrait.php | 262 +++++++++++++++++++++++++ tests/Traits/RestTraitDefaultsTest.php | 70 +++++++ tests/Traits/RestTraitTest.php | 248 +++++++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 src/Traits/RestTrait.php create mode 100644 tests/Traits/RestTraitDefaultsTest.php create mode 100644 tests/Traits/RestTraitTest.php diff --git a/src/Traits/RestTrait.php b/src/Traits/RestTrait.php new file mode 100644 index 0000000..76316ad --- /dev/null +++ b/src/Traits/RestTrait.php @@ -0,0 +1,262 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Traits; + +use Phalcon\Talon\Exceptions\ResponseNotDispatched; +use Phalcon\Talon\Talon; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\BrowserKit\Exception\BadMethodCallException; +use Symfony\Component\BrowserKit\HttpBrowser; +use Symfony\Component\BrowserKit\Response; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function base64_encode; +use function http_build_query; +use function in_array; +use function is_string; +use function json_encode; +use function ltrim; +use function rtrim; +use function str_contains; +use function str_replace; +use function str_starts_with; +use function strtolower; +use function strtoupper; + +/** + * @mixin \PHPUnit\Framework\TestCase + */ +trait RestTrait +{ + private ?AbstractBrowser $restClient = null; + + /** @var array */ + private array $restRequestHeaders = []; + + public function amBearerAuthenticated(string $token): void + { + $this->haveHttpHeader('Authorization', 'Bearer ' . $token); + } + + public function amHttpAuthenticated(string $username, string $password): void + { + $this->haveHttpHeader('Authorization', 'Basic ' . base64_encode($username . ':' . $password)); + } + + public function grabHttpHeader(string $name): ?string + { + // getHeader() is declared string|array|null, but the array arm is only + // reachable with $first = false. + /** @var string|null $value */ + $value = $this->restResponse()->getHeader($name); + + return $value; + } + + public function grabResponse(): string + { + return $this->restResponse()->getContent(); + } + + public function grabResponseCode(): int + { + return $this->restResponse()->getStatusCode(); + } + + public function haveHttpHeader(string $name, string $value): void + { + $this->restRequestHeaders[$this->headerKey($name)] = $value; + $this->syncRequestHeaders(); + } + + /** + * @param array|string $params + */ + public function send(string $method, string $url, array|string $params = []): void + { + $method = strtoupper($method); + $uri = $this->restUrl($url); + + if (is_string($params)) { + $this->restBrowser()->request($method, $uri, [], [], [], $params); + + return; + } + + if (in_array($method, ['DELETE', 'GET', 'HEAD', 'OPTIONS'], true)) { + if ([] !== $params) { + $uri .= (str_contains($uri, '?') ? '&' : '?') . http_build_query($params); + } + + $this->restBrowser()->request($method, $uri); + + return; + } + + if ($this->sendsJson()) { + $this->restBrowser()->request($method, $uri, [], [], [], (string) json_encode($params)); + + return; + } + + $this->restBrowser()->request($method, $uri, $params); + } + + /** + * @param array $params + */ + public function sendDelete(string $url, array $params = []): void + { + $this->send('DELETE', $url, $params); + } + + /** + * @param array $params + */ + public function sendGet(string $url, array $params = []): void + { + $this->send('GET', $url, $params); + } + + /** + * @param array $params + */ + public function sendHead(string $url, array $params = []): void + { + $this->send('HEAD', $url, $params); + } + + /** + * @param array $params + */ + public function sendOptions(string $url, array $params = []): void + { + $this->send('OPTIONS', $url, $params); + } + + /** + * @param array|string $params + */ + public function sendPatch(string $url, array|string $params = []): void + { + $this->send('PATCH', $url, $params); + } + + /** + * @param array|string $params + */ + public function sendPost(string $url, array|string $params = []): void + { + $this->send('POST', $url, $params); + } + + /** + * @param array|string $params + */ + public function sendPut(string $url, array|string $params = []): void + { + $this->send('PUT', $url, $params); + } + + public function startFollowingRedirects(): void + { + $this->restBrowser()->followRedirects(true); + } + + public function stopFollowingRedirects(): void + { + $this->restBrowser()->followRedirects(false); + } + + public function unsetHttpHeader(string $name): void + { + unset($this->restRequestHeaders[$this->headerKey($name)]); + $this->syncRequestHeaders(); + } + + protected function restBaseUrl(): string + { + $url = Talon::settings()->get('rest_url'); + + return is_string($url) && '' !== $url ? $url : 'http://127.0.0.1:8080'; + } + + protected function restBrowser(): AbstractBrowser + { + if (null !== $this->restClient) { + return $this->restClient; + } + + $client = new HttpBrowser($this->restHttpClient()); + $client->followRedirects(false); + + $this->restClient = $client; + $this->syncRequestHeaders(); + + return $client; + } + + /** + * The transport seam. Returning null lets HttpBrowser build a real client; + * a test overrides this with a MockHttpClient so the suite exercises the + * same request-building path without a live server. + */ + protected function restHttpClient(): ?HttpClientInterface + { + return null; + } + + private function headerKey(string $name): string + { + return 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + } + + private function restResponse(): Response + { + try { + return $this->restBrowser()->getInternalResponse(); + } catch (BadMethodCallException) { + throw new ResponseNotDispatched(); + } + } + + private function restUrl(string $url): string + { + if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { + return $url; + } + + return rtrim($this->restBaseUrl(), '/') . '/' . ltrim($url, '/'); + } + + private function sendsJson(): bool + { + return str_contains( + strtolower($this->restRequestHeaders['HTTP_CONTENT_TYPE'] ?? ''), + 'json' + ); + } + + private function syncRequestHeaders(): void + { + if (null === $this->restClient) { + return; + } + + // setServerParameters() replaces the whole set (defaults plus what we + // pass), which is the only way to drop a header - BrowserKit has no + // removeServerParameter(). + $this->restClient->setServerParameters($this->restRequestHeaders); + } +} diff --git a/tests/Traits/RestTraitDefaultsTest.php b/tests/Traits/RestTraitDefaultsTest.php new file mode 100644 index 0000000..52d21f4 --- /dev/null +++ b/tests/Traits/RestTraitDefaultsTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Settings; +use Phalcon\Talon\Talon; +use Phalcon\Talon\Traits\RestTrait; + +/** + * Covers the seams RestTraitTest overrides: the default (real) HTTP client and + * the base URL resolved from Settings. + */ +final class RestTraitDefaultsTest extends AbstractUnitTestCase +{ + use RestTrait; + + protected function tearDown(): void + { + Talon::reset(); + + parent::tearDown(); + } + + public function testDefaultHttpClientIsNull(): void + { + $this->assertNull($this->restHttpClient()); + } + + public function testRestBaseUrlComesFromSettings(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => 'http://from.settings:9000', + ])); + + $this->assertSame('http://from.settings:9000', $this->restBaseUrl()); + } + + public function testRestBaseUrlFallsBackWhenEmpty(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => '', + ])); + + $this->assertSame('http://127.0.0.1:8080', $this->restBaseUrl()); + } + + public function testRestBaseUrlFallsBackWhenNotAString(): void + { + Talon::useSettings(Settings::fromArray([ + 'root' => '/app', + 'rest_url' => 123, + ])); + + $this->assertSame('http://127.0.0.1:8080', $this->restBaseUrl()); + } +} diff --git a/tests/Traits/RestTraitTest.php b/tests/Traits/RestTraitTest.php new file mode 100644 index 0000000..aa50807 --- /dev/null +++ b/tests/Traits/RestTraitTest.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\Exceptions\ResponseNotDispatched; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Traits\RestTrait; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function array_shift; +use function base64_encode; +use function is_string; +use function json_decode; + +final class RestTraitTest extends AbstractUnitTestCase +{ + use RestTrait; + + /** + * @var array, body: string}> + */ + private array $requests = []; + + /** @var array */ + private array $responses = []; + + public function testAbsoluteHttpsUrlIsNotPrefixed(): void + { + $this->sendGet('https://secure.test/ping'); + + $this->assertSame('https://secure.test/ping', $this->requests[0]['url']); + } + + public function testAbsoluteUrlIsNotPrefixed(): void + { + $this->sendGet('http://other.test/ping'); + + $this->assertSame('http://other.test/ping', $this->requests[0]['url']); + } + + public function testAmBearerAuthenticatedSetsAuthorizationHeader(): void + { + $this->amBearerAuthenticated('tok123'); + $this->sendGet('/secure'); + + $this->assertContains('authorization: Bearer tok123', $this->requests[0]['headers']); + } + + public function testAmHttpAuthenticatedSetsBasicHeader(): void + { + $this->amHttpAuthenticated('sarah', 'secret'); + $this->sendGet('/secure'); + + $this->assertContains( + 'authorization: Basic ' . base64_encode('sarah:secret'), + $this->requests[0]['headers'] + ); + } + + public function testGrabHttpHeader(): void + { + $this->sendGet('/companies'); + + $this->assertSame('application/json', $this->grabHttpHeader('Content-Type')); + $this->assertNull($this->grabHttpHeader('X-Absent')); + } + + public function testGrabResponseAndCode(): void + { + $this->sendGet('/companies'); + + $this->assertSame('{"data":{"id":1}}', $this->grabResponse()); + $this->assertSame(201, $this->grabResponseCode()); + } + + public function testGrabResponseBeforeSendingThrows(): void + { + $this->expectException(ResponseNotDispatched::class); + + $this->grabResponse(); + } + + public function testHeaderPersistsAcrossRequests(): void + { + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->sendGet('/two'); + + $this->assertContains('x-token: abc', $this->requests[0]['headers']); + $this->assertContains('x-token: abc', $this->requests[1]['headers']); + } + + public function testQueryAppendedWithAmpersandWhenUrlHasQuery(): void + { + $this->sendGet('/companies?filter=x', ['page' => 2]); + + $this->assertSame('http://api.test:8080/companies?filter=x&page=2', $this->requests[0]['url']); + } + + public function testSendGetAppendsQueryParameters(): void + { + $this->sendGet('/companies', ['page' => 2]); + + $this->assertSame('http://api.test:8080/companies?page=2', $this->requests[0]['url']); + } + + public function testSendGetBuildsAbsoluteUrlFromBase(): void + { + $this->sendGet('/companies'); + + $this->assertSame('GET', $this->requests[0]['method']); + $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); + } + + public function testSendPostAcceptsRawStringBody(): void + { + $this->sendPost('/login', '{"raw":true}'); + + $this->assertSame('{"raw":true}', $this->requests[0]['body']); + } + + public function testSendPostSendsFormParametersByDefault(): void + { + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame('POST', $this->requests[0]['method']); + $this->assertStringContainsString('username', $this->requests[0]['body']); + $this->assertStringContainsString('sarah', $this->requests[0]['body']); + } + + public function testSendPostSerializesJsonWhenContentTypeIsJson(): void + { + $this->haveHttpHeader('Content-Type', 'application/json'); + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame( + ['username' => 'sarah'], + json_decode($this->requests[0]['body'], true) + ); + } + + public function testStartFollowingRedirectsFollowsLocation(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + new MockResponse('done', ['http_code' => 200]), + ]; + + $this->startFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(2, $this->requests); + $this->assertSame('http://api.test:8080/target', $this->requests[1]['url']); + $this->assertSame('done', $this->grabResponse()); + } + + public function testStopFollowingRedirectsDoesNotFollow(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->stopFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + + public function testUnsetHttpHeaderRemovesIt(): void + { + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->unsetHttpHeader('X-Token'); + $this->sendGet('/two'); + + $this->assertContains('x-token: abc', $this->requests[0]['headers']); + $this->assertNotContains('x-token: abc', $this->requests[1]['headers']); + } + + public function testVerbsDispatchTheRightMethod(): void + { + $this->sendPut('/a'); + $this->sendPatch('/b'); + $this->sendDelete('/c'); + $this->sendHead('/d'); + $this->sendOptions('/e'); + + $this->assertSame('PUT', $this->requests[0]['method']); + $this->assertSame('PATCH', $this->requests[1]['method']); + $this->assertSame('DELETE', $this->requests[2]['method']); + $this->assertSame('HEAD', $this->requests[3]['method']); + $this->assertSame('OPTIONS', $this->requests[4]['method']); + } + + protected function restBaseUrl(): string + { + return 'http://api.test:8080'; + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient( + /** + * @param array $options + */ + function (string $method, string $url, array $options): MockResponse { + /** @var array $headers */ + $headers = $options['headers'] ?? []; + $body = $options['body'] ?? ''; + + $this->requests[] = [ + 'method' => $method, + 'url' => $url, + 'headers' => $headers, + 'body' => is_string($body) ? $body : '', + ]; + + return array_shift($this->responses) ?? new MockResponse( + '{"data":{"id":1}}', + [ + 'http_code' => 201, + 'response_headers' => ['Content-Type' => 'application/json'], + ] + ); + } + ); + } +} From 28a1e4a601507ebbcceab2716495a534191221e9 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:19 -0500 Subject: [PATCH 09/21] [#19] - adding RestAssertionsTrait and AbstractRestTestCase Mirrors the BrowserTrait/BrowserAssertionsTrait/AbstractBrowserTestCase split: actions read as verbs, assertions are assertX, and the assertions trait reaches the response through abstract declarations that RestTrait satisfies. AbstractRestTestCase needs no $_SESSION isolation, unlike AbstractBrowserTestCase - requests cross a real HTTP boundary, so there is no in-process session to leak between tests. --- src/PHPUnit/AbstractRestTestCase.php | 27 +++ src/Traits/RestAssertionsTrait.php | 181 ++++++++++++++++++ tests/Traits/RestAssertionsTraitTest.php | 168 ++++++++++++++++ .../Unit/PHPUnit/AbstractRestTestCaseTest.php | 42 ++++ 4 files changed, 418 insertions(+) create mode 100644 src/PHPUnit/AbstractRestTestCase.php create mode 100644 src/Traits/RestAssertionsTrait.php create mode 100644 tests/Traits/RestAssertionsTraitTest.php create mode 100644 tests/Unit/PHPUnit/AbstractRestTestCaseTest.php diff --git a/src/PHPUnit/AbstractRestTestCase.php b/src/PHPUnit/AbstractRestTestCase.php new file mode 100644 index 0000000..35ec9e9 --- /dev/null +++ b/src/PHPUnit/AbstractRestTestCase.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\PHPUnit; + +use Phalcon\Talon\Traits\RestAssertionsTrait; +use Phalcon\Talon\Traits\RestTrait; + +/** + * Requests cross a real HTTP boundary, so - unlike AbstractBrowserTestCase - + * there is no in-process $_SESSION to isolate between tests. + */ +abstract class AbstractRestTestCase extends AbstractUnitTestCase +{ + use RestAssertionsTrait; + use RestTrait; +} diff --git a/src/Traits/RestAssertionsTrait.php b/src/Traits/RestAssertionsTrait.php new file mode 100644 index 0000000..e0ec122 --- /dev/null +++ b/src/Traits/RestAssertionsTrait.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Traits; + +use Phalcon\Talon\Http\JsonSubset; +use Phalcon\Talon\Http\JsonType; + +use function is_array; +use function json_decode; +use function json_last_error; +use function sprintf; + +use const JSON_ERROR_NONE; + +/** + * @mixin \PHPUnit\Framework\TestCase + */ +trait RestAssertionsTrait +{ + public function assertHttpHeader(string $name, ?string $value = null): void + { + $actual = $this->grabHttpHeader($name); + + $this->assertNotNull($actual, sprintf("Failed asserting that header '%s' is present", $name)); + + if (null !== $value) { + $this->assertSame($value, $actual); + } + } + + public function assertNoHttpHeader(string $name, ?string $value = null): void + { + $actual = $this->grabHttpHeader($name); + + if (null === $value) { + $this->assertNull($actual, sprintf("Failed asserting that header '%s' is absent", $name)); + + return; + } + + $this->assertNotSame($value, $actual); + } + + public function assertResponseCodeIs(int $code): void + { + $this->assertSame($code, $this->grabResponseCode()); + } + + public function assertResponseCodeIsClientError(): void + { + $this->assertResponseCodeInRange(400, 499); + } + + public function assertResponseCodeIsNot(int $code): void + { + $this->assertNotSame($code, $this->grabResponseCode()); + } + + public function assertResponseCodeIsRedirection(): void + { + $this->assertResponseCodeInRange(300, 399); + } + + public function assertResponseCodeIsServerError(): void + { + $this->assertResponseCodeInRange(500, 599); + } + + public function assertResponseCodeIsSuccessful(): void + { + $this->assertResponseCodeInRange(200, 299); + } + + public function assertResponseContains(string $text): void + { + $this->assertStringContainsString($text, $this->grabResponse()); + } + + /** + * @param array $json + */ + public function assertResponseContainsJson(array $json): void + { + $this->assertTrue( + JsonSubset::contains($this->decodedResponse(), $json), + 'Failed asserting that the response contains the given JSON fragment. Response: ' + . $this->grabResponse() + ); + } + + public function assertResponseEquals(string $expected): void + { + $this->assertSame($expected, $this->grabResponse()); + } + + public function assertResponseIsJson(): void + { + $content = $this->grabResponse(); + json_decode($content, true); + + $this->assertSame( + JSON_ERROR_NONE, + json_last_error(), + 'Failed asserting that the response is valid JSON. Response: ' . $content + ); + } + + /** + * @param array $types + */ + public function assertResponseMatchesJsonType(array $types): void + { + $error = JsonType::match($this->decodedResponse(), $types); + + $this->assertNull( + $error, + sprintf('Failed asserting that the response matches the given JSON types. %s', (string) $error) + ); + } + + public function assertResponseNotContains(string $text): void + { + $this->assertStringNotContainsString($text, $this->grabResponse()); + } + + /** + * @param array $json + */ + public function assertResponseNotContainsJson(array $json): void + { + $this->assertFalse( + JsonSubset::contains($this->decodedResponse(), $json), + 'Failed asserting that the response does not contain the given JSON fragment.' + ); + } + + /** + * @param array $types + */ + public function assertResponseNotMatchesJsonType(array $types): void + { + $this->assertNotNull( + JsonType::match($this->decodedResponse(), $types), + 'Failed asserting that the response does not match the given JSON types.' + ); + } + abstract public function grabHttpHeader(string $name): ?string; + + abstract public function grabResponse(): string; + + abstract public function grabResponseCode(): int; + + private function assertResponseCodeInRange(int $low, int $high): void + { + $code = $this->grabResponseCode(); + + $this->assertGreaterThanOrEqual($low, $code); + $this->assertLessThanOrEqual($high, $code); + } + + /** + * @return array + */ + private function decodedResponse(): array + { + $decoded = json_decode($this->grabResponse(), true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php new file mode 100644 index 0000000..944e5d8 --- /dev/null +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use PHPUnit\Framework\AssertionFailedError; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class RestAssertionsTraitTest extends AbstractRestTestCase +{ + private string $body = ''; + + /** @var array */ + private array $headers = ['Content-Type' => 'application/json']; + + private int $status = 200; + + public function testAssertResponseCodeIs(): void + { + $this->respondWith('{}', 201); + + $this->assertResponseCodeIs(201); + $this->assertResponseCodeIsNot(200); + } + + public function testAssertResponseCodeIsFails(): void + { + $this->respondWith('{}', 404); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseCodeIs(200); + } + + public function testAssertResponseContainsJsonMatchesFragment(): void + { + $this->respondWith('{"jsonapi":{"version":"1.0"},"data":[{"id":1,"name":"Acme"}]}'); + + $this->assertResponseContainsJson(['data' => [['name' => 'Acme']]]); + $this->assertResponseNotContainsJson(['data' => [['name' => 'Other']]]); + } + + public function testAssertResponseIsJson(): void + { + $this->respondWith('{"a":1}'); + + $this->assertResponseIsJson(); + } + + public function testAssertResponseIsJsonFailsOnGarbage(): void + { + $this->respondWith('not json'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseIsJson(); + } + + public function testAssertResponseMatchesJsonType(): void + { + $this->respondWith( + '{"jsonapi":{"version":"1.0"},"meta":{"timestamp":"2026-07-15T10:30:00+00:00","hash":"abc"}}' + ); + + $this->assertResponseMatchesJsonType([ + 'jsonapi' => ['version' => 'string'], + 'meta' => [ + 'timestamp' => 'string:date', + 'hash' => 'string', + ], + ]); + } + + public function testAssertResponseMatchesJsonTypeFailureNamesThePath(): void + { + $this->respondWith('{"meta":{"hash":1}}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/meta\.hash/'); + + $this->assertResponseMatchesJsonType(['meta' => ['hash' => 'string']]); + } + + public function testAssertResponseNotMatchesJsonType(): void + { + $this->respondWith('{"meta":{"hash":1}}'); + + $this->assertResponseNotMatchesJsonType(['meta' => ['hash' => 'string']]); + } + + public function testBodyAssertions(): void + { + $this->respondWith('{"a":1}'); + + $this->assertResponseEquals('{"a":1}'); + $this->assertResponseContains('"a"'); + $this->assertResponseNotContains('"b"'); + } + + public function testHeaderAssertions(): void + { + $this->respondWith('{}'); + + $this->assertHttpHeader('Content-Type'); + $this->assertHttpHeader('Content-Type', 'application/json'); + $this->assertNoHttpHeader('X-Absent'); + $this->assertNoHttpHeader('Content-Type', 'text/html'); + } + + public function testJsonAssertionsTreatANonObjectResponseAsEmpty(): void + { + $this->respondWith('"just a string"'); + + $this->assertResponseNotContainsJson(['a' => 1]); + } + + public function testRangeAssertions(): void + { + $this->respondWith('{}', 204); + $this->assertResponseCodeIsSuccessful(); + + $this->respondWith('{}', 302); + $this->assertResponseCodeIsRedirection(); + + $this->respondWith('{}', 404); + $this->assertResponseCodeIsClientError(); + + $this->respondWith('{}', 503); + $this->assertResponseCodeIsServerError(); + } + + protected function restBaseUrl(): string + { + return 'http://api.test:8080'; + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient(fn (): MockResponse => new MockResponse( + $this->body, + [ + 'http_code' => $this->status, + 'response_headers' => $this->headers, + ] + )); + } + + private function respondWith(string $body, int $status = 200): void + { + $this->body = $body; + $this->status = $status; + + $this->sendGet('/x'); + } +} diff --git a/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php b/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php new file mode 100644 index 0000000..a7aeb5a --- /dev/null +++ b/tests/Unit/PHPUnit/AbstractRestTestCaseTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Unit\PHPUnit; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Talon\Traits\RestAssertionsTrait; +use Phalcon\Talon\Traits\RestTrait; + +use function class_parents; +use function class_uses; +use function in_array; + +final class AbstractRestTestCaseTest extends AbstractUnitTestCase +{ + public function testExtendsAbstractUnitTestCase(): void + { + $this->assertContains( + AbstractUnitTestCase::class, + (array) class_parents(AbstractRestTestCase::class) + ); + } + + public function testUsesBothRestTraits(): void + { + $uses = (array) class_uses(AbstractRestTestCase::class); + + $this->assertTrue(in_array(RestTrait::class, $uses, true)); + $this->assertTrue(in_array(RestAssertionsTrait::class, $uses, true)); + } +} From 596d6e4656c683be391c4c7c91c90c10500c141e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 10:52:19 -0500 Subject: [PATCH 10/21] [#19] - updating changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2746932..1ef2d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,16 @@ ### Changed +- Added `symfony/http-client` and `symfony/mime` (both `^6.4 || ^7.0`) as dependencies. `Symfony\Component\BrowserKit\HttpBrowser` needs the first to make real requests and the second to build a request body - browser-kit lists both as `require-dev`, not `require`, so neither is pulled in transitively. Mime is not only for file uploads: `HttpBrowser` raises `You cannot pass non-empty bodies as the Mime component is not installed` for every method other than `GET`/`HEAD`, before it inspects the body at all. On PHP 8.1 Composer resolves the 6.4 LTS line; Symfony 7.x requires PHP 8.2. [#19](https://github.com/phalcon/talon/issues/19) + ### Added +- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. Talon's browser surface was HTML-shaped (`clickLink`/`fillField`/`pressButton`) because it was built against `vokuro` and `invo`; a JSON:API application had no counterpart and could not be tested at all. Requests go over real HTTP via `Symfony\Component\BrowserKit\HttpBrowser`, so the application is exercised the way a client exercises it - the in-process `Browser\Client` cannot serve here because it never maps request headers into `$_SERVER`, and a raw JSON body cannot reach `php://input` in-process. `restHttpClient()` is the seam a test overrides with a `MockHttpClient` to drive the same request-building path without a live server. The base URL comes from `TALON_REST_URL` (default `http://127.0.0.1:8080`). [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\HttpCode` - HTTP status constants plus `getDescription()`, which returns the `404 (Not Found)` form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test, so that asserting an application's emitted status string against it actually asserts something. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored, so a map can describe just the part of an envelope a test cares about. Types are strict: an int does not satisfy `float`. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonSubset` - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. [#19](https://github.com/phalcon/talon/issues/19) +- Added `TALON_REST_URL` to `Settings::fromEnv()`, readable via `Settings::get('rest_url')` and defaulting to `http://127.0.0.1:8080`. [#19](https://github.com/phalcon/talon/issues/19) + ### Fixed - `AbstractUnitTestCase::setUp()` now guards its `Di::reset()` call behind `phalconAvailable()`, so packages that use the Talon abstract test cases without Phalcon (or without the DI component) no longer hit a fatal `Class "Phalcon\Di\Di" not found` at setup time - because `AbstractUnitTestCase` is the root of the hierarchy, every abstract (`AbstractServicesTestCase`/`AbstractDatabaseTestCase`/`AbstractBrowserTestCase`/`AbstractFunctionalTestCase`) inherits the fix and can be extended without a DI. When Phalcon is available the reset still runs unchanged. [#14](https://github.com/phalcon/talon/issues/14) From 339549c441db5cda2a67614c52cb32dee49fdb72 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 11:20:09 -0500 Subject: [PATCH 11/21] [#19] - hardening the REST tests against mutation Coverage was already 100%, but coverage only proves a line ran - infection showed 84 mutants surviving in the new code. The largest group was PublicVisibility: reducing a public assertion to protected broke nothing, because the tests called it via $this from inside the class. Fakes/Rest/PublicApiConsumer follows the existing Fakes/Browser/PublicApiConsumer precedent and exercises the surface from outside the hierarchy, which is what actually pins the visibility. DefaultSeamConsumer exists separately because PublicApiConsumer overrides both seams, and an override masks whether the trait's own definitions are still reachable from a child. The rest were tests asserting only the happy path: removing the inner assertion of an assert*() left every test passing, and the range bounds were never pinned from outside (a test using 404 cannot tell 400..499 from 401..498). Each assertion now has a failing case, and each bound is pinned from both sides. --- tests/Fakes/Rest/DefaultSeamConsumer.php | 35 +++++ tests/Fakes/Rest/PublicApiConsumer.php | 69 ++++++++++ tests/Traits/RestAssertionsTraitTest.php | 164 +++++++++++++++++++++++ tests/Traits/RestPublicApiTest.php | 113 ++++++++++++++++ tests/Traits/RestTraitTest.php | 114 +++++++++++++++- tests/Unit/Http/JsonTypeTest.php | 33 +++++ 6 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 tests/Fakes/Rest/DefaultSeamConsumer.php create mode 100644 tests/Fakes/Rest/PublicApiConsumer.php create mode 100644 tests/Traits/RestPublicApiTest.php diff --git a/tests/Fakes/Rest/DefaultSeamConsumer.php b/tests/Fakes/Rest/DefaultSeamConsumer.php new file mode 100644 index 0000000..48c934c --- /dev/null +++ b/tests/Fakes/Rest/DefaultSeamConsumer.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Fakes\Rest; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Reaches restBaseUrl()/restHttpClient() from a subclass without overriding + * them - PublicApiConsumer overrides both, which masks the trait's own + * definitions and hides whether they are still reachable from a child class. + */ +final class DefaultSeamConsumer extends AbstractRestTestCase +{ + public function rawBaseUrl(): string + { + return $this->restBaseUrl(); + } + + public function rawHttpClient(): ?HttpClientInterface + { + return $this->restHttpClient(); + } +} diff --git a/tests/Fakes/Rest/PublicApiConsumer.php b/tests/Fakes/Rest/PublicApiConsumer.php new file mode 100644 index 0000000..2af0c7e --- /dev/null +++ b/tests/Fakes/Rest/PublicApiConsumer.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Fakes\Rest; + +use Phalcon\Talon\PHPUnit\AbstractRestTestCase; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Exposes the REST traits to a scope outside the AbstractRestTestCase + * hierarchy, so tests can verify the public API stays publicly callable and + * the protected restBaseUrl()/restBrowser()/restHttpClient() seams stay + * subclass-accessible. + */ +final class PublicApiConsumer extends AbstractRestTestCase +{ + public const BODY = '{"jsonapi":{"version":"1.0"},"data":[{"id":1,"name":"Acme"}]}'; + + private int $status = 200; + + public function rawBaseUrl(): string + { + return $this->restBaseUrl(); + } + + public function rawBrowser(): AbstractBrowser + { + return $this->restBrowser(); + } + + public function rawHttpClient(): ?HttpClientInterface + { + return $this->restHttpClient(); + } + + public function respondWith(int $status): void + { + $this->status = $status; + } + + protected function restBaseUrl(): string + { + return 'http://api.test:8080'; + } + + protected function restHttpClient(): HttpClientInterface + { + return new MockHttpClient(fn (): MockResponse => new MockResponse( + self::BODY, + [ + 'http_code' => $this->status, + 'response_headers' => ['Content-Type' => 'application/json'], + ] + )); + } +} diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php index 944e5d8..c0e36d6 100644 --- a/tests/Traits/RestAssertionsTraitTest.php +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -27,6 +27,43 @@ final class RestAssertionsTraitTest extends AbstractRestTestCase private array $headers = ['Content-Type' => 'application/json']; private int $status = 200; + /** + * Each range assertion is pinned at both bounds, one code inside and one + * outside, so shifting either literal by one is observable. + * + * @return array + */ + public static function providerRangeBoundaries(): array + { + return [ + '199 not successful' => [199, 'assertResponseCodeIsSuccessful', false], + '200 successful' => [200, 'assertResponseCodeIsSuccessful', true], + '299 successful' => [299, 'assertResponseCodeIsSuccessful', true], + '300 not successful' => [300, 'assertResponseCodeIsSuccessful', false], + '299 not redirection' => [299, 'assertResponseCodeIsRedirection', false], + '300 redirection' => [300, 'assertResponseCodeIsRedirection', true], + '399 redirection' => [399, 'assertResponseCodeIsRedirection', true], + '400 not redirection' => [400, 'assertResponseCodeIsRedirection', false], + '399 not client error' => [399, 'assertResponseCodeIsClientError', false], + '400 client error' => [400, 'assertResponseCodeIsClientError', true], + '499 client error' => [499, 'assertResponseCodeIsClientError', true], + '500 not client error' => [500, 'assertResponseCodeIsClientError', false], + '499 not server error' => [499, 'assertResponseCodeIsServerError', false], + '500 server error' => [500, 'assertResponseCodeIsServerError', true], + '599 server error' => [599, 'assertResponseCodeIsServerError', true], + '600 not server error' => [600, 'assertResponseCodeIsServerError', false], + ]; + } + + public function testAssertHttpHeaderFailsWhenTheHeaderIsAbsent(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches("/header 'X-Absent' is present/"); + + $this->assertHttpHeader('X-Absent'); + } public function testAssertResponseCodeIs(): void { @@ -45,6 +82,38 @@ public function testAssertResponseCodeIsFails(): void $this->assertResponseCodeIs(200); } + public function testAssertResponseCodeIsNotFailsWhenTheCodeMatches(): void + { + $this->respondWith('{}', 200); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseCodeIsNot(200); + } + + public function testAssertResponseContainsFailsWhenTheTextIsAbsent(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseContains('"b"'); + } + + /** + * The failure message carries the response body - without it a failing + * fragment assertion says nothing about what actually came back. + */ + public function testAssertResponseContainsJsonFailsAndNamesTheResponse(): void + { + $this->respondWith('{"data":[{"name":"Acme"}]}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/Response: \{"data":\[\{"name":"Acme"\}\]\}/'); + + $this->assertResponseContainsJson(['data' => [['name' => 'Nope']]]); + } + public function testAssertResponseContainsJsonMatchesFragment(): void { $this->respondWith('{"jsonapi":{"version":"1.0"},"data":[{"id":1,"name":"Acme"}]}'); @@ -53,6 +122,15 @@ public function testAssertResponseContainsJsonMatchesFragment(): void $this->assertResponseNotContainsJson(['data' => [['name' => 'Other']]]); } + public function testAssertResponseEqualsFailsOnADifferentBody(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseEquals('{"a":2}'); + } + public function testAssertResponseIsJson(): void { $this->respondWith('{"a":1}'); @@ -69,6 +147,16 @@ public function testAssertResponseIsJsonFailsOnGarbage(): void $this->assertResponseIsJson(); } + public function testAssertResponseIsJsonFailureNamesTheResponse(): void + { + $this->respondWith('not json at all'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/Response: not json at all/'); + + $this->assertResponseIsJson(); + } + public function testAssertResponseMatchesJsonType(): void { $this->respondWith( @@ -94,6 +182,24 @@ public function testAssertResponseMatchesJsonTypeFailureNamesThePath(): void $this->assertResponseMatchesJsonType(['meta' => ['hash' => 'string']]); } + public function testAssertResponseNotContainsFailsWhenTheTextIsPresent(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotContains('"a"'); + } + + public function testAssertResponseNotContainsJsonFailsWhenTheFragmentMatches(): void + { + $this->respondWith('{"data":[{"name":"Acme"}]}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotContainsJson(['data' => [['name' => 'Acme']]]); + } + public function testAssertResponseNotMatchesJsonType(): void { $this->respondWith('{"meta":{"hash":1}}'); @@ -101,6 +207,15 @@ public function testAssertResponseNotMatchesJsonType(): void $this->assertResponseNotMatchesJsonType(['meta' => ['hash' => 'string']]); } + public function testAssertResponseNotMatchesJsonTypeFailsWhenTheTypesMatch(): void + { + $this->respondWith('{"meta":{"hash":"abc"}}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseNotMatchesJsonType(['meta' => ['hash' => 'string']]); + } + public function testBodyAssertions(): void { $this->respondWith('{"a":1}'); @@ -120,6 +235,15 @@ public function testHeaderAssertions(): void $this->assertNoHttpHeader('Content-Type', 'text/html'); } + public function testHeaderAssertionsFailOnAWrongValue(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertHttpHeader('Content-Type', 'text/html'); + } + public function testJsonAssertionsTreatANonObjectResponseAsEmpty(): void { $this->respondWith('"just a string"'); @@ -127,6 +251,46 @@ public function testJsonAssertionsTreatANonObjectResponseAsEmpty(): void $this->assertResponseNotContainsJson(['a' => 1]); } + public function testNoHttpHeaderFailsWhenTheHeaderIsPresent(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoHttpHeader('Content-Type'); + } + + public function testNoHttpHeaderFailsWhenTheValueMatches(): void + { + $this->respondWith('{}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoHttpHeader('Content-Type', 'application/json'); + } + + /** + * The range assertions are two comparisons against literal bounds, so each + * bound must be pinned from both sides - a test that only uses a mid-range + * code cannot tell 400..499 from 401..498. + * + * @dataProvider providerRangeBoundaries + */ + public function testRangeAssertionBoundaries(int $code, string $method, bool $shouldPass): void + { + $this->respondWith('{}', $code); + + if (!$shouldPass) { + $this->expectException(AssertionFailedError::class); + } + + $this->$method(); + + // On the passing path $method() has already asserted the bounds; assert + // the code round-tripped so the test carries an assertion of its own. + $this->assertSame($code, $this->grabResponseCode()); + } + public function testRangeAssertions(): void { $this->respondWith('{}', 204); diff --git a/tests/Traits/RestPublicApiTest.php b/tests/Traits/RestPublicApiTest.php new file mode 100644 index 0000000..3372fdb --- /dev/null +++ b/tests/Traits/RestPublicApiTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Talon\Tests\Traits; + +use Phalcon\Talon\Tests\Fakes\Rest\DefaultSeamConsumer; +use Phalcon\Talon\Tests\Fakes\Rest\PublicApiConsumer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\BrowserKit\HttpBrowser; +use Symfony\Component\HttpClient\MockHttpClient; + +final class RestPublicApiTest extends TestCase +{ + public function testActionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $subject->haveHttpHeader('X-Token', 'abc'); + $subject->unsetHttpHeader('X-Token'); + $subject->amBearerAuthenticated('tok'); + $subject->amHttpAuthenticated('sarah', 'secret'); + $subject->startFollowingRedirects(); + $subject->stopFollowingRedirects(); + + $subject->send('GET', '/x'); + $subject->sendGet('/companies'); + $subject->sendPost('/companies', ['name' => 'Acme']); + $subject->sendPut('/companies/1', ['name' => 'Acme']); + $subject->sendPatch('/companies/1', ['name' => 'Acme']); + $subject->sendDelete('/companies/1'); + $subject->sendHead('/companies'); + $subject->sendOptions('/companies'); + + $this->assertSame(200, $subject->grabResponseCode()); + $this->assertSame('application/json', $subject->grabHttpHeader('Content-Type')); + $this->assertSame(PublicApiConsumer::BODY, $subject->grabResponse()); + } + + public function testAssertionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + $subject->sendGet('/companies'); + + $subject->assertResponseCodeIs(200); + $subject->assertResponseCodeIsNot(404); + $subject->assertResponseCodeIsSuccessful(); + $subject->assertResponseIsJson(); + $subject->assertResponseEquals(PublicApiConsumer::BODY); + $subject->assertResponseContains('Acme'); + $subject->assertResponseNotContains('Nope'); + $subject->assertResponseContainsJson(['data' => [['name' => 'Acme']]]); + $subject->assertResponseNotContainsJson(['data' => [['name' => 'Nope']]]); + $subject->assertResponseMatchesJsonType(['jsonapi' => ['version' => 'string']]); + $subject->assertResponseNotMatchesJsonType(['jsonapi' => ['version' => 'integer']]); + $subject->assertHttpHeader('Content-Type'); + $subject->assertHttpHeader('Content-Type', 'application/json'); + $subject->assertNoHttpHeader('X-Absent'); + $subject->assertNoHttpHeader('Content-Type', 'text/html'); + + $this->assertSame(200, $subject->grabResponseCode()); + } + + /** + * PublicApiConsumer overrides both seams, so it cannot show whether the + * trait's own definitions stay reachable from a child class. This one does + * not override them. + */ + public function testDefaultSeamsStayReachableFromASubclass(): void + { + $subject = new DefaultSeamConsumer('restDefaultSeams'); + + $this->assertSame('http://127.0.0.1:8080', $subject->rawBaseUrl()); + $this->assertNull($subject->rawHttpClient()); + } + + public function testRangeAssertionsAreCallableFromOutsideTheHierarchy(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $subject->respondWith(302); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsRedirection(); + + $subject->respondWith(404); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsClientError(); + + $subject->respondWith(503); + $subject->sendGet('/x'); + $subject->assertResponseCodeIsServerError(); + + $this->assertSame(503, $subject->grabResponseCode()); + } + + public function testSeamsStaySubclassAccessible(): void + { + $subject = new PublicApiConsumer('restPublicApi'); + + $this->assertSame('http://api.test:8080', $subject->rawBaseUrl()); + $this->assertInstanceOf(HttpBrowser::class, $subject->rawBrowser()); + $this->assertInstanceOf(MockHttpClient::class, $subject->rawHttpClient()); + } +} diff --git a/tests/Traits/RestTraitTest.php b/tests/Traits/RestTraitTest.php index aa50807..33fb8d0 100644 --- a/tests/Traits/RestTraitTest.php +++ b/tests/Traits/RestTraitTest.php @@ -29,6 +29,8 @@ final class RestTraitTest extends AbstractUnitTestCase { use RestTrait; + private string $baseUrl = 'http://api.test:8080'; + /** * @var array, body: string}> */ @@ -37,6 +39,19 @@ final class RestTraitTest extends AbstractUnitTestCase /** @var array */ private array $responses = []; + /** + * @return array + */ + public static function providerBodylessVerbs(): array + { + return [ + ['DELETE'], + ['GET'], + ['HEAD'], + ['OPTIONS'], + ]; + } + public function testAbsoluteHttpsUrlIsNotPrefixed(): void { $this->sendGet('https://secure.test/ping'); @@ -70,6 +85,60 @@ public function testAmHttpAuthenticatedSetsBasicHeader(): void ); } + /** + * A trailing slash on the configured base URL must not produce '//' once + * the path is joined on. + */ + public function testBaseUrlTrailingSlashIsNotDoubled(): void + { + $this->baseUrl = 'http://api.test:8080/'; + $this->sendGet('/companies'); + + $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); + } + + /** + * GET/HEAD/OPTIONS/DELETE put their params in the query string; every other + * verb puts them in the body. Asserting the query proves each verb is in + * the bodyless set - a body-carrying verb would leave the URL bare. + * + * @dataProvider providerBodylessVerbs + */ + public function testBodylessVerbsSendParamsAsQuery(string $method): void + { + $this->send($method, '/companies', ['page' => 2]); + + $this->assertSame($method, $this->requests[0]['method']); + $this->assertSame('http://api.test:8080/companies?page=2', $this->requests[0]['url']); + $this->assertSame('', $this->requests[0]['body']); + } + + public function testContentTypeMatchIsCaseInsensitive(): void + { + $this->haveHttpHeader('Content-Type', 'APPLICATION/JSON'); + $this->sendPost('/login', ['username' => 'sarah']); + + $this->assertSame( + ['username' => 'sarah'], + json_decode($this->requests[0]['body'], true) + ); + } + + public function testDoesNotFollowRedirectsByDefault(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + public function testGrabHttpHeader(): void { $this->sendGet('/companies'); @@ -103,6 +172,21 @@ public function testHeaderPersistsAcrossRequests(): void $this->assertContains('x-token: abc', $this->requests[1]['headers']); } + /** + * A header set once the browser already exists must still reach the wire - + * restBrowser() only syncs on construction, so haveHttpHeader() has to push + * it itself. + */ + public function testHeaderSetAfterTheFirstRequestApplies(): void + { + $this->sendGet('/one'); + $this->haveHttpHeader('X-Late', 'v'); + $this->sendGet('/two'); + + $this->assertNotContains('x-late: v', $this->requests[0]['headers']); + $this->assertContains('x-late: v', $this->requests[1]['headers']); + } + public function testQueryAppendedWithAmpersandWhenUrlHasQuery(): void { $this->sendGet('/companies?filter=x', ['page' => 2]); @@ -152,6 +236,13 @@ public function testSendPostSerializesJsonWhenContentTypeIsJson(): void ); } + public function testSendUppercasesTheMethod(): void + { + $this->send('get', '/companies'); + + $this->assertSame('GET', $this->requests[0]['method']); + } + public function testStartFollowingRedirectsFollowsLocation(): void { $this->responses = [ @@ -170,6 +261,27 @@ public function testStartFollowingRedirectsFollowsLocation(): void $this->assertSame('done', $this->grabResponse()); } + /** + * stopFollowingRedirects() must actually turn following off, not merely + * agree with the constructor default. + */ + public function testStopFollowingRedirectsAfterStartingDoesNotFollow(): void + { + $this->responses = [ + new MockResponse('', [ + 'http_code' => 302, + 'response_headers' => ['Location' => 'http://api.test:8080/target'], + ]), + ]; + + $this->startFollowingRedirects(); + $this->stopFollowingRedirects(); + $this->sendGet('/start'); + + $this->assertCount(1, $this->requests); + $this->assertSame(302, $this->grabResponseCode()); + } + public function testStopFollowingRedirectsDoesNotFollow(): void { $this->responses = [ @@ -214,7 +326,7 @@ public function testVerbsDispatchTheRightMethod(): void protected function restBaseUrl(): string { - return 'http://api.test:8080'; + return $this->baseUrl; } protected function restHttpClient(): HttpClientInterface diff --git a/tests/Unit/Http/JsonTypeTest.php b/tests/Unit/Http/JsonTypeTest.php index b8a24a6..da53104 100644 --- a/tests/Unit/Http/JsonTypeTest.php +++ b/tests/Unit/Http/JsonTypeTest.php @@ -42,6 +42,39 @@ public function testIntegerIsNotAFloat(): void $this->assertNull(JsonType::match(['a' => 1.5], ['a' => 'float'])); } + /** + * A decoded JSON array is int-keyed, so the path built for a nested map at + * an integer key must still be a string. + */ + public function testIntegerKeyedNestedMapReportsAStringPath(): void + { + $this->assertSame( + "Key '0.a' expected 'string', got 'int'", + JsonType::match([['a' => 1]], [0 => ['a' => 'string']]) + ); + } + + /** + * A nested map that matches must not end the walk - the keys after it still + * have to be checked. + */ + public function testKeyAfterAMatchingNestedMapIsStillChecked(): void + { + $this->assertSame( + "Key 'meta.hash' expected 'string', got 'int'", + JsonType::match( + [ + 'jsonapi' => ['version' => '1.0'], + 'meta' => ['hash' => 1], + ], + [ + 'jsonapi' => ['version' => 'string'], + 'meta' => ['hash' => 'string'], + ] + ) + ); + } + public function testMatchesTheRestApiEnvelope(): void { $actual = [ From 05e387356f3ec127772a6a4e5a74f1e190ff8021 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 11:20:10 -0500 Subject: [PATCH 12/21] [#19] - updating infection config for the REST surface Two changes. The line references for Settings::getDatabaseDsn were stale: applying ordered_class_elements moved the method from 186 to 246, so the MatchArmRemoval and Throw_ ignores pointed at unrelated lines. Any line-scoped ignore in a file the reorder touched had to be rechecked; these two were the only ones that moved. Worth knowing for next time - line-scoped ignores do not survive reordering, which is why the new entries below are method-scoped. Four new ignores for genuinely unkillable mutants, each unobservable from a test rather than merely inconvenient to cover: a break that only short-circuits a scan, an explode() limit the DSL can never reach, and two casts that are no-ops on every path that can observe them. --- resources/infection.json5 | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/resources/infection.json5 b/resources/infection.json5 index 6eaedcb..14bd5d2 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -29,8 +29,22 @@ "Phalcon\\Talon\\Cli\\Input::wantsVersion" ] }, + "Break_": { + // The break only stops scanning once a match is found; without it + // the loop keeps looking and $found stays true. Same answer, more + // work - not observable from a test. + "ignore": ["Phalcon\\Talon\\Http\\JsonSubset::containsList"] + }, "CastString": { "ignore": [ + // json_encode() only returns false for input it cannot encode + // (malformed UTF-8, recursion, INF/NAN). Params reaching a REST + // verb are always encodable, so the cast is a no-op. + "Phalcon\\Talon\\Traits\\RestTrait::send", + // (string) applies to $error, which is only rendered when + // assertNull() fails - i.e. when it is a non-null string. The + // cast is a no-op on every path that can observe it. + "Phalcon\\Talon\\Traits\\RestAssertionsTrait::assertResponseMatchesJsonType", // Discovered suite names are non-numeric filename stems and // preg_replace with a fixed valid pattern never returns null; // these casts only satisfy static analysis. Method-scoped: @@ -79,9 +93,14 @@ "ignore": ["Phalcon\\Talon\\Bootstrap\\Runner::initEnvironment::99"] }, "IncrementInteger": { - // Every accumulated query starts with the "\n" separator, so - // substr($query, 1, ...) only removes what trim() strips anyway. - "ignore": ["Phalcon\\Talon\\Database\\StatementSplitter::split::64"] + "ignore": [ + // Every accumulated query starts with the "\n" separator, so + // substr($query, 1, ...) only removes what trim() strips anyway. + "Phalcon\\Talon\\Database\\StatementSplitter::split::64", + // The type DSL has no spec carrying two colons, so raising + // explode()'s limit cannot change how a spec splits. + "Phalcon\\Talon\\Http\\JsonType::matchesSingle" + ] }, "LogicalOrAllSubExprNegation": { "ignore": [ @@ -99,7 +118,7 @@ "MatchArmRemoval": { // The default arm is dead code: getDatabaseOptions() already threw // UnknownDriver for any driver outside DRIVERS before the match. - "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::186"] + "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::250"] }, "MethodCallRemoval": { "ignore": [ @@ -116,7 +135,7 @@ "Throw_": { // Same dead default arm; line-scoped so the live throw in // getDatabaseOptions() stays mutable. - "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::201"] + "ignore": ["Phalcon\\Talon\\Settings::getDatabaseDsn::265"] }, "UnwrapTrim": { // Lines are trimmed before being accumulated, so the final From 013402bf94cbb5e49bf32b733bdb0352627983fd Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 12:01:23 -0500 Subject: [PATCH 13/21] [#19] - removing infection from composer until the PHP floor moves infection requires thecodingmachine/safe at dev-master, which autoloads 80 files eagerly - they land in vendor/composer/autoload_files.php, so they are parsed on every require of the autoloader, not only when infection runs. On PHP 8.5 that emits deprecations across the whole matrix rather than just the one cell mutation testing uses. The lock happened to pin safe at 4fbc008, an older dev-master commit that still behaves, so this had not surfaced here yet. dev-master is a moving target and dependabot is active, so it was a matter of time. resources/infection.json5 and the test-mutation script stay, as does the extension-installer allow-plugin entry, so the setup comes back with a single composer require --dev. The CI step is commented out rather than deleted for the same reason. Mutation testing was run before this removal: MSI 99%, with no escaped mutants in the REST surface. Revisit once the minimum PHP version moves past 8.1 and infection can be brought back. --- .github/workflows/main.yml | 7 +- composer.json | 1 - composer.lock | 923 +------------------------------------ 3 files changed, 6 insertions(+), 925 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe0d9b1..7ca7d3b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -164,8 +164,11 @@ jobs: - name: Tests with coverage run: composer test-coverage - - name: Mutation testing - run: composer test-mutation + # Mutation testing is not run here: infection pulls thecodingmachine/safe + # at dev-master, which autoloads 80 files eagerly on every request and + # emits deprecations on PHP 8.5. The package is out of composer.json until + # the minimum PHP version moves past 8.1; resources/infection.json5 stays, + # so `composer require --dev infection/infection` restores the setup. - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0 diff --git a/composer.json b/composer.json index aae95eb..b29d2e6 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,6 @@ "ext-pdo": "*", "ext-sqlite3": "*", "friendsofphp/php-cs-fixer": "^3", - "infection/infection": "^0.29.9", "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/phalcon": "^6.0@alpha", diff --git a/composer.lock b/composer.lock index 328df99..f7eb07c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1bd51af2c37305704eef87a5db18b8c8", + "content-hash": "12ef0c0be4c7597a869ca71b8c7f564f", "packages": [ { "name": "masterminds/html5", @@ -1312,94 +1312,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "colinodell/json5", - "version": "v3.0.0", - "source": { - "type": "git", - "url": "https://github.com/colinodell/json5.git", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/colinodell/json5/zipball/5724d21bc5c910c2560af1b8915f0cc0163579c8", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^8.0" - }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.7.0", - "phpstan/phpstan": "^1.10.57", - "scrutinizer/ocular": "^1.9", - "squizlabs/php_codesniffer": "^3.8.1", - "symfony/finder": "^6.0|^7.0", - "symfony/phpunit-bridge": "^7.0.3" - }, - "bin": [ - "bin/json5" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "files": [ - "src/global.php" - ], - "psr-4": { - "ColinODell\\Json5\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Developer" - } - ], - "description": "UTF-8 compatible JSON5 parser for PHP", - "homepage": "https://github.com/colinodell/json5", - "keywords": [ - "JSON5", - "json", - "json5_decode", - "json_decode" - ], - "support": { - "issues": "https://github.com/colinodell/json5/issues", - "source": "https://github.com/colinodell/json5/tree/v3.0.0" - }, - "funding": [ - { - "url": "https://www.colinodell.com/sponsor", - "type": "custom" - }, - { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" - }, - { - "url": "https://github.com/colinodell", - "type": "github" - }, - { - "url": "https://www.patreon.com/colinodell", - "type": "patreon" - } - ], - "time": "2024-02-09T13:06:12+00:00" - }, { "name": "composer/pcre", "version": "3.4.0", @@ -1901,430 +1813,6 @@ ], "time": "2026-07-10T09:23:21+00:00" }, - { - "name": "infection/abstract-testframework-adapter", - "version": "0.5.0", - "source": { - "type": "git", - "url": "https://github.com/infection/abstract-testframework-adapter.git", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^2.17", - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\AbstractTestFramework\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Abstract Test Framework Adapter for Infection", - "support": { - "issues": "https://github.com/infection/abstract-testframework-adapter/issues", - "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-17T18:49:12+00:00" - }, - { - "name": "infection/extension-installer", - "version": "0.1.2", - "source": { - "type": "git", - "url": "https://github.com/infection/extension-installer.git", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/extension-installer/zipball/9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1 || ^2.0" - }, - "require-dev": { - "composer/composer": "^1.9 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.18, <2.19", - "infection/infection": "^0.15.2", - "php-coveralls/php-coveralls": "^2.4", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.10", - "phpstan/phpstan-phpunit": "^0.12.6", - "phpstan/phpstan-strict-rules": "^0.12.2", - "phpstan/phpstan-webmozart-assert": "^0.12.2", - "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.8" - }, - "type": "composer-plugin", - "extra": { - "class": "Infection\\ExtensionInstaller\\Plugin" - }, - "autoload": { - "psr-4": { - "Infection\\ExtensionInstaller\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Infection Extension Installer", - "support": { - "issues": "https://github.com/infection/extension-installer/issues", - "source": "https://github.com/infection/extension-installer/tree/0.1.2" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-10-20T22:08:34+00:00" - }, - { - "name": "infection/include-interceptor", - "version": "0.2.5", - "source": { - "type": "git", - "url": "https://github.com/infection/include-interceptor.git", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", - "shasum": "" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "infection/infection": "^0.15.0", - "phan/phan": "^2.4 || ^3", - "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "^0.12.8", - "phpunit/phpunit": "^8.5", - "vimeo/psalm": "^3.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\StreamWrapper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", - "support": { - "issues": "https://github.com/infection/include-interceptor/issues", - "source": "https://github.com/infection/include-interceptor/tree/0.2.5" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-09T10:03:57+00:00" - }, - { - "name": "infection/infection", - "version": "0.29.9", - "source": { - "type": "git", - "url": "https://github.com/infection/infection.git", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "shasum": "" - }, - "require": { - "colinodell/json5": "^2.2 || ^3.0", - "composer-runtime-api": "^2.0", - "composer/xdebug-handler": "^2.0 || ^3.0", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "fidry/cpu-core-counter": "^0.4.0 || ^0.5.0 || ^1.0", - "infection/abstract-testframework-adapter": "^0.5.0", - "infection/extension-installer": "^0.1.0", - "infection/include-interceptor": "^0.2.5", - "infection/mutator": "^0.4", - "justinrainbow/json-schema": "^5.3", - "nikic/php-parser": "^5.3", - "ondram/ci-detector": "^4.1.0", - "php": "^8.1", - "sanmai/later": "^0.1.1", - "sanmai/pipeline": "^5.1 || ^6", - "sebastian/diff": "^3.0.2 || ^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/safe": "dev-master as 2.5.0", - "webmozart/assert": "^1.11" - }, - "conflict": { - "antecedent/patchwork": "<2.1.25", - "dg/bypass-finals": "<1.4.1", - "phpunit/php-code-coverage": ">9,<9.1.4 || >9.2.17,<9.2.21" - }, - "require-dev": { - "ext-simplexml": "*", - "fidry/makefile": "^1.0", - "helmich/phpunit-json-assert": "^3.0", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.10.15", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpstan/phpstan-webmozart-assert": "^1.0.2", - "phpunit/phpunit": "^10.5", - "rector/rector": "^1.0", - "sidz/phpstan-rules": "^0.4", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.2.0" - }, - "bin": [ - "bin/infection" - ], - "type": "library", - "autoload": { - "psr-4": { - "Infection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com", - "homepage": "https://twitter.com/maks_rafalko" - }, - { - "name": "Oleg Zhulnev", - "homepage": "https://github.com/sidz" - }, - { - "name": "Gert de Pagter", - "homepage": "https://github.com/BackEndTea" - }, - { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com", - "homepage": "https://twitter.com/tfidry" - }, - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com", - "homepage": "https://www.alexeykopytko.com" - }, - { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" - } - ], - "description": "Infection is a Mutation Testing framework for PHP. The mutation adequacy score can be used to measure the effectiveness of a test set in terms of its ability to detect faults.", - "keywords": [ - "coverage", - "mutant", - "mutation framework", - "mutation testing", - "testing", - "unit testing" - ], - "support": { - "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.29.9" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2024-12-08T22:23:44+00:00" - }, - { - "name": "infection/mutator", - "version": "0.4.1", - "source": { - "type": "git", - "url": "https://github.com/infection/mutator.git", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^10" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\Mutator\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", - "support": { - "issues": "https://github.com/infection/mutator/issues", - "source": "https://github.com/infection/mutator/tree/0.4.1" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2025-04-29T08:19:52+00:00" - }, - { - "name": "justinrainbow/json-schema", - "version": "5.3.4", - "source": { - "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.4" - }, - "time": "2026-05-04T18:54:58+00:00" - }, { "name": "matthiasmullie/minify", "version": "1.3.75", @@ -2566,84 +2054,6 @@ }, "time": "2025-12-06T11:56:16+00:00" }, - { - "name": "ondram/ci-detector", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/OndraM/ci-detector.git", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.13.2", - "lmc/coding-standard": "^3.0.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.2.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpunit/phpunit": "^9.6.13" - }, - "type": "library", - "autoload": { - "psr-4": { - "OndraM\\CiDetector\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ondřej Machulda", - "email": "ondrej.machulda@gmail.com" - } - ], - "description": "Detect continuous integration environment and provide unified access to properties of current build", - "keywords": [ - "CircleCI", - "Codeship", - "Wercker", - "adapter", - "appveyor", - "aws", - "aws codebuild", - "azure", - "azure devops", - "azure pipelines", - "bamboo", - "bitbucket", - "buddy", - "ci-info", - "codebuild", - "continuous integration", - "continuousphp", - "devops", - "drone", - "github", - "gitlab", - "interface", - "jenkins", - "pipelines", - "sourcehut", - "teamcity", - "travis" - ], - "support": { - "issues": "https://github.com/OndraM/ci-detector/issues", - "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" - }, - "time": "2024-03-12T13:22:30+00:00" - }, { "name": "pds/composer-script-names", "version": "1.0.0", @@ -4160,135 +3570,6 @@ ], "time": "2024-06-11T12:45:25+00:00" }, - { - "name": "sanmai/later", - "version": "0.1.5", - "source": { - "type": "git", - "url": "https://github.com/sanmai/later.git", - "reference": "cf5164557d19930295892094996f049ea12ba14d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/cf5164557d19930295892094996f049ea12ba14d", - "reference": "cf5164557d19930295892094996f049ea12ba14d", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.35.1", - "infection/infection": ">=0.27.6", - "phan/phan": ">=2", - "php-coveralls/php-coveralls": "^2.0", - "phpstan/phpstan": ">=1.4.5", - "phpunit/phpunit": ">=9.5 <10", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "0.1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Later\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "Later: deferred wrapper object", - "support": { - "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.5" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-12-06T02:36:26+00:00" - }, - { - "name": "sanmai/pipeline", - "version": "6.12", - "source": { - "type": "git", - "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.17", - "infection/infection": ">=0.10.5", - "league/pipeline": "^0.3 || ^1.0", - "phan/phan": ">=1.1", - "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "v6.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Pipeline\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "General-purpose collections pipeline", - "support": { - "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-10-17T02:22:57+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -6338,150 +5619,6 @@ ], "time": "2026-05-12T11:44:19+00:00" }, - { - "name": "thecodingmachine/safe", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "4fbc0088994d486b0012d67116b90825f17a9309" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/4fbc0088994d486b0012d67116b90825f17a9309", - "reference": "4fbc0088994d486b0012d67116b90825f17a9309", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "default-branch": true, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/master" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-03-03T12:18:24+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -6531,64 +5668,6 @@ } ], "time": "2025-11-17T20:03:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" - }, - "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], From faa47a39c114c1c8ab42bd451e6c9f81cb804300 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 12:42:14 -0500 Subject: [PATCH 14/21] [#19] - adjusting changelog --- CHANGELOG.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef2d74..a752588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,29 @@ # Changelog -## [0.7.0](https://github.com/phalcon/talon/releases/tag/v0.7.0) (2026-07-10) +## [0.8.0](https://github.com/phalcon/talon/releases/tag/v0.8.0) (2026-07-15) ### Changed -- Added `symfony/http-client` and `symfony/mime` (both `^6.4 || ^7.0`) as dependencies. `Symfony\Component\BrowserKit\HttpBrowser` needs the first to make real requests and the second to build a request body - browser-kit lists both as `require-dev`, not `require`, so neither is pulled in transitively. Mime is not only for file uploads: `HttpBrowser` raises `You cannot pass non-empty bodies as the Mime component is not installed` for every method other than `GET`/`HEAD`, before it inspects the body at all. On PHP 8.1 Composer resolves the 6.4 LTS line; Symfony 7.x requires PHP 8.2. [#19](https://github.com/phalcon/talon/issues/19) - ### Added -- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. Talon's browser surface was HTML-shaped (`clickLink`/`fillField`/`pressButton`) because it was built against `vokuro` and `invo`; a JSON:API application had no counterpart and could not be tested at all. Requests go over real HTTP via `Symfony\Component\BrowserKit\HttpBrowser`, so the application is exercised the way a client exercises it - the in-process `Browser\Client` cannot serve here because it never maps request headers into `$_SERVER`, and a raw JSON body cannot reach `php://input` in-process. `restHttpClient()` is the seam a test overrides with a `MockHttpClient` to drive the same request-building path without a live server. The base URL comes from `TALON_REST_URL` (default `http://127.0.0.1:8080`). [#19](https://github.com/phalcon/talon/issues/19) +- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. [#19](https://github.com/phalcon/talon/issues/19) - Added `Phalcon\Talon\Http\HttpCode` - HTTP status constants plus `getDescription()`, which returns the `404 (Not Found)` form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test, so that asserting an application's emitted status string against it actually asserts something. [#19](https://github.com/phalcon/talon/issues/19) -- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored, so a map can describe just the part of an envelope a test cares about. Types are strict: an int does not satisfy `float`. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored. Types are strict: an int does not satisfy `float`. [#19](https://github.com/phalcon/talon/issues/19) - Added `Phalcon\Talon\Http\JsonSubset` - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. [#19](https://github.com/phalcon/talon/issues/19) - Added `TALON_REST_URL` to `Settings::fromEnv()`, readable via `Settings::get('rest_url')` and defaulting to `http://127.0.0.1:8080`. [#19](https://github.com/phalcon/talon/issues/19) ### Fixed +### Removed + +## [0.7.0](https://github.com/phalcon/talon/releases/tag/v0.7.0) (2026-07-10) + +### Changed + +### Added + +### Fixed + - `AbstractUnitTestCase::setUp()` now guards its `Di::reset()` call behind `phalconAvailable()`, so packages that use the Talon abstract test cases without Phalcon (or without the DI component) no longer hit a fatal `Class "Phalcon\Di\Di" not found` at setup time - because `AbstractUnitTestCase` is the root of the hierarchy, every abstract (`AbstractServicesTestCase`/`AbstractDatabaseTestCase`/`AbstractBrowserTestCase`/`AbstractFunctionalTestCase`) inherits the fix and can be extended without a DI. When Phalcon is available the reset still runs unchanged. [#14](https://github.com/phalcon/talon/issues/14) - `AbstractBrowserTestCase` now clears `$_SESSION` in `tearDown()` as well as `setUp()`, so a test that logs a user in no longer leaks that session into a following test that does not reset it itself. The browser fixture session persists across the in-process app rebuilds and `Di::reset()` does not clear it, which surfaced as a random-order failure of `FixtureSmokeTest::testSecuredShowsGuestWithoutSession` (it read a `sarah` session left behind by `BrowserTraitTest`). [#16](https://github.com/phalcon/talon/pull/16) From 2802af15a0458281929b4e88759d557ae38143d9 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 12:42:26 -0500 Subject: [PATCH 15/21] [#19] - fixing the RestTrait findings from review Raw string bodies went out as text/plain. HttpBrowser wraps an unlabelled string body in a TextPart, so sendPost($url, json_encode($data)) - the most natural call on this surface - silently mislabelled every request. String bodies now default to application/json, per request, and only when the caller has not named a type. The test asserted only the body, which is how a path at 100% coverage and 99% MSI still shipped wrong: the line ran and the mutants died, but nothing looked at the header. Request headers now ride the per-request $server argument instead of setServerParameters(). BrowserKit merges that over its own bag rather than replacing it, so a caller's setServerParameter() survives. The old approach rebuilt the whole bag on every header change and silently dropped anything it had not set - aftermath of needing an unset that BrowserKit has no API for. syncRequestHeaders() is gone with it, and so is the ordering subtlety where restBrowser() had to sync after constructing. useRestBaseUrl() makes the base URL injectable rather than only overridable. It was reachable solely through Talon's process-global Settings, which is why PublicApiConsumer had to override the seam - and that override is what masked the visibility mutants and forced a second fake. $files is back, now that mime is a dependency and the reason for dropping it is void. It takes a plain path as well as the $_FILES shape: BrowserKit's answer to a plain path is to stop reading the list and send nothing, silently, which is a poor thing to hand someone. --- src/Traits/RestTrait.php | 113 ++++++++++++++++++++++-------- tests/Traits/RestTraitTest.php | 122 ++++++++++++++++++++++++++++++--- 2 files changed, 196 insertions(+), 39 deletions(-) diff --git a/src/Traits/RestTrait.php b/src/Traits/RestTrait.php index 76316ad..a222344 100644 --- a/src/Traits/RestTrait.php +++ b/src/Traits/RestTrait.php @@ -22,6 +22,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; use function base64_encode; +use function basename; use function http_build_query; use function in_array; use function is_string; @@ -39,6 +40,8 @@ */ trait RestTrait { + private ?string $restBaseUrlOverride = null; + private ?AbstractBrowser $restClient = null; /** @var array */ @@ -54,6 +57,10 @@ public function amHttpAuthenticated(string $username, string $password): void $this->haveHttpHeader('Authorization', 'Basic ' . base64_encode($username . ':' . $password)); } + /** + * Returns the first value of a response header. A header sent more than + * once (Set-Cookie being the usual case) reports only its first value. + */ public function grabHttpHeader(string $name): ?string { // getHeader() is declared string|array|null, but the array arm is only @@ -74,43 +81,62 @@ public function grabResponseCode(): int return $this->restResponse()->getStatusCode(); } + /** + * Sets a request header for this and every later request, until it is + * unset. + */ public function haveHttpHeader(string $name, string $value): void { $this->restRequestHeaders[$this->headerKey($name)] = $value; - $this->syncRequestHeaders(); } /** * @param array|string $params + * @param array $files */ - public function send(string $method, string $url, array|string $params = []): void + public function send(string $method, string $url, array|string $params = [], array $files = []): void { $method = strtoupper($method); $uri = $this->restUrl($url); if (is_string($params)) { - $this->restBrowser()->request($method, $uri, [], [], [], $params); + // HttpBrowser wraps an unlabelled string body in a TextPart, which + // goes out as text/plain. On a REST surface a raw body is JSON far + // more often than not, so name it - per request, and only when the + // caller has not named it already (the union keeps the left side). + $server = $this->restRequestHeaders + [$this->headerKey('Content-Type') => 'application/json']; + + $this->restBrowser()->request($method, $uri, [], $this->normalizeFiles($files), $server, $params); return; } - if (in_array($method, ['DELETE', 'GET', 'HEAD', 'OPTIONS'], true)) { + // Files force a body, so a bodyless verb carrying them falls through to + // the multipart path rather than silently dropping them. + if ([] === $files && in_array($method, ['DELETE', 'GET', 'HEAD', 'OPTIONS'], true)) { if ([] !== $params) { $uri .= (str_contains($uri, '?') ? '&' : '?') . http_build_query($params); } - $this->restBrowser()->request($method, $uri); + $this->restBrowser()->request($method, $uri, [], [], $this->restRequestHeaders); return; } - if ($this->sendsJson()) { - $this->restBrowser()->request($method, $uri, [], [], [], (string) json_encode($params)); + if ([] === $files && $this->sendsJson()) { + $this->restBrowser()->request( + $method, + $uri, + [], + [], + $this->restRequestHeaders, + (string) json_encode($params) + ); return; } - $this->restBrowser()->request($method, $uri, $params); + $this->restBrowser()->request($method, $uri, $params, $this->normalizeFiles($files), $this->restRequestHeaders); } /** @@ -147,26 +173,29 @@ public function sendOptions(string $url, array $params = []): void /** * @param array|string $params + * @param array $files */ - public function sendPatch(string $url, array|string $params = []): void + public function sendPatch(string $url, array|string $params = [], array $files = []): void { - $this->send('PATCH', $url, $params); + $this->send('PATCH', $url, $params, $files); } /** * @param array|string $params + * @param array $files */ - public function sendPost(string $url, array|string $params = []): void + public function sendPost(string $url, array|string $params = [], array $files = []): void { - $this->send('POST', $url, $params); + $this->send('POST', $url, $params, $files); } /** * @param array|string $params + * @param array $files */ - public function sendPut(string $url, array|string $params = []): void + public function sendPut(string $url, array|string $params = [], array $files = []): void { - $this->send('PUT', $url, $params); + $this->send('PUT', $url, $params, $files); } public function startFollowingRedirects(): void @@ -182,11 +211,24 @@ public function stopFollowingRedirects(): void public function unsetHttpHeader(string $name): void { unset($this->restRequestHeaders[$this->headerKey($name)]); - $this->syncRequestHeaders(); + } + + /** + * Points this test at a base URL, ahead of the one Settings resolves. + * Without it the URL comes from TALON_REST_URL via Talon's shared Settings, + * which is process-global and cannot vary per test. + */ + public function useRestBaseUrl(string $url): void + { + $this->restBaseUrlOverride = $url; } protected function restBaseUrl(): string { + if (null !== $this->restBaseUrlOverride) { + return $this->restBaseUrlOverride; + } + $url = Talon::settings()->get('rest_url'); return is_string($url) && '' !== $url ? $url : 'http://127.0.0.1:8080'; @@ -202,7 +244,6 @@ protected function restBrowser(): AbstractBrowser $client->followRedirects(false); $this->restClient = $client; - $this->syncRequestHeaders(); return $client; } @@ -217,11 +258,39 @@ protected function restHttpClient(): ?HttpClientInterface return null; } + /** + * Header names map onto PHP's HTTP_* server convention, so '-' and '_' are + * indistinguishable: 'Content-Type' and 'Content_Type' address the same + * header and the later call wins. + */ private function headerKey(string $name): string { return 'HTTP_' . strtoupper(str_replace('-', '_', $name)); } + /** + * Accepts either a plain path or the $_FILES shape BrowserKit wants. + * BrowserKit's own handling of a plain path is to stop reading the list and + * send nothing at all, so a caller who passes the obvious thing gets a + * silently file-less request. + * + * @param array $files + * + * @return array + */ + private function normalizeFiles(array $files): array + { + $normalized = []; + + foreach ($files as $field => $file) { + $normalized[$field] = is_string($file) + ? ['tmp_name' => $file, 'name' => basename($file)] + : $file; + } + + return $normalized; + } + private function restResponse(): Response { try { @@ -247,16 +316,4 @@ private function sendsJson(): bool 'json' ); } - - private function syncRequestHeaders(): void - { - if (null === $this->restClient) { - return; - } - - // setServerParameters() replaces the whole set (defaults plus what we - // pass), which is the only way to drop a header - BrowserKit has no - // removeServerParameter(). - $this->restClient->setServerParameters($this->restRequestHeaders); - } } diff --git a/tests/Traits/RestTraitTest.php b/tests/Traits/RestTraitTest.php index 33fb8d0..a146e82 100644 --- a/tests/Traits/RestTraitTest.php +++ b/tests/Traits/RestTraitTest.php @@ -13,6 +13,7 @@ namespace Phalcon\Talon\Tests\Traits; +use Closure; use Phalcon\Talon\Exceptions\ResponseNotDispatched; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Talon\Traits\RestTrait; @@ -24,13 +25,12 @@ use function base64_encode; use function is_string; use function json_decode; +use function stripos; final class RestTraitTest extends AbstractUnitTestCase { use RestTrait; - private string $baseUrl = 'http://api.test:8080'; - /** * @var array, body: string}> */ @@ -39,6 +39,13 @@ final class RestTraitTest extends AbstractUnitTestCase /** @var array */ private array $responses = []; + protected function setUp(): void + { + parent::setUp(); + + $this->useRestBaseUrl('http://api.test:8080'); + } + /** * @return array */ @@ -91,7 +98,7 @@ public function testAmHttpAuthenticatedSetsBasicHeader(): void */ public function testBaseUrlTrailingSlashIsNotDoubled(): void { - $this->baseUrl = 'http://api.test:8080/'; + $this->useRestBaseUrl('http://api.test:8080/'); $this->sendGet('/companies'); $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); @@ -113,6 +120,22 @@ public function testBodylessVerbsSendParamsAsQuery(string $method): void $this->assertSame('', $this->requests[0]['body']); } + /** + * A caller-set server parameter must survive header changes - rebuilding the + * whole server bag used to wipe it. + */ + public function testCallerServerParametersSurviveHeaderChanges(): void + { + $this->restBrowser()->setServerParameter('HTTP_X_CUSTOM', 'kept'); + $this->haveHttpHeader('X-Token', 'abc'); + $this->sendGet('/one'); + $this->unsetHttpHeader('X-Token'); + $this->sendGet('/two'); + + $this->assertContains('x-custom: kept', $this->requests[0]['headers']); + $this->assertContains('x-custom: kept', $this->requests[1]['headers']); + } + public function testContentTypeMatchIsCaseInsensitive(): void { $this->haveHttpHeader('Content-Type', 'APPLICATION/JSON'); @@ -194,6 +217,15 @@ public function testQueryAppendedWithAmpersandWhenUrlHasQuery(): void $this->assertSame('http://api.test:8080/companies?filter=x&page=2', $this->requests[0]['url']); } + public function testRawStringBodyKeepsACallerSuppliedContentType(): void + { + $this->haveHttpHeader('Content-Type', 'application/xml'); + $this->sendPost('/login', ''); + + $this->assertContains('content-type: application/xml', $this->requests[0]['headers']); + $this->assertNotContains('content-type: application/json', $this->requests[0]['headers']); + } + public function testSendGetAppendsQueryParameters(): void { $this->sendGet('/companies', ['page' => 2]); @@ -209,11 +241,16 @@ public function testSendGetBuildsAbsoluteUrlFromBase(): void $this->assertSame('http://api.test:8080/companies', $this->requests[0]['url']); } - public function testSendPostAcceptsRawStringBody(): void + /** + * HttpBrowser would otherwise wrap an unlabelled string body in a TextPart + * and send it as text/plain, which a JSON API rejects. + */ + public function testSendPostAcceptsRawStringBodyAndNamesItJson(): void { $this->sendPost('/login', '{"raw":true}'); $this->assertSame('{"raw":true}', $this->requests[0]['body']); + $this->assertContains('content-type: application/json', $this->requests[0]['headers']); } public function testSendPostSendsFormParametersByDefault(): void @@ -236,6 +273,29 @@ public function testSendPostSerializesJsonWhenContentTypeIsJson(): void ); } + /** + * A plain path is what a caller reaches for first, and BrowserKit's own + * response to one is to send no files at all, silently - so both shapes + * have to work. + */ + public function testSendPostUploadsAFileGivenAPlainPath(): void + { + $this->sendPost('/upload', ['name' => 'Acme'], ['doc' => __FILE__]); + + $this->assertSame('POST', $this->requests[0]['method']); + $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); + $this->assertStringContainsString('RestTraitTest.php', $this->requests[0]['body']); + $this->assertStringContainsString('Acme', $this->requests[0]['body']); + } + + public function testSendPostUploadsAFileGivenTheFilesShape(): void + { + $this->sendPost('/upload', [], ['doc' => ['tmp_name' => __FILE__, 'name' => 'custom.php']]); + + $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); + $this->assertStringContainsString('custom.php', $this->requests[0]['body']); + } + public function testSendUppercasesTheMethod(): void { $this->send('get', '/companies'); @@ -309,6 +369,14 @@ public function testUnsetHttpHeaderRemovesIt(): void $this->assertNotContains('x-token: abc', $this->requests[1]['headers']); } + public function testUseRestBaseUrlOverridesSettings(): void + { + $this->useRestBaseUrl('http://other.test:9000'); + $this->sendGet('/companies'); + + $this->assertSame('http://other.test:9000/companies', $this->requests[0]['url']); + } + public function testVerbsDispatchTheRightMethod(): void { $this->sendPut('/a'); @@ -324,11 +392,6 @@ public function testVerbsDispatchTheRightMethod(): void $this->assertSame('OPTIONS', $this->requests[4]['method']); } - protected function restBaseUrl(): string - { - return $this->baseUrl; - } - protected function restHttpClient(): HttpClientInterface { return new MockHttpClient( @@ -338,13 +401,12 @@ protected function restHttpClient(): HttpClientInterface function (string $method, string $url, array $options): MockResponse { /** @var array $headers */ $headers = $options['headers'] ?? []; - $body = $options['body'] ?? ''; $this->requests[] = [ 'method' => $method, 'url' => $url, 'headers' => $headers, - 'body' => is_string($body) ? $body : '', + 'body' => $this->readBody($options['body'] ?? ''), ]; return array_shift($this->responses) ?? new MockResponse( @@ -357,4 +419,42 @@ function (string $method, string $url, array $options): MockResponse { } ); } + + private function contentTypeOf(int $index): string + { + foreach ($this->requests[$index]['headers'] as $header) { + if (stripos($header, 'content-type:') === 0) { + return $header; + } + } + + return ''; + } + + /** + * A multipart body arrives as the Closure Symfony normalizes an iterable + * into, not a string; drain it so tests can assert on what was sent. + */ + private function readBody(mixed $body): string + { + if (is_string($body)) { + return $body; + } + + if (!$body instanceof Closure) { + return ''; + } + + $content = ''; + while (true) { + $chunk = $body(16372); + if (!is_string($chunk) || '' === $chunk) { + break; + } + + $content .= $chunk; + } + + return $content; + } } From 08ee9f07039aecc138b4846540e4ad94566f90f8 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 12:42:26 -0500 Subject: [PATCH 16/21] [#19] - fixing the JSON matching findings from review ['data' => []] asserted nothing. Read as a pure subset an empty list matches every document, so the fragment only proved the key existed - and rest-api's seeSuccessJsonResponse() defaults straight into that shape, so the conversion would have inherited a suite of assertions that could not fail. An empty expected list now means empty. An empty fragment at the top level is rejected outright, since it can only be a caller mistake. 'float' rejected whole numbers. JSON has a single number type, so json_decode returns int for {"price": 10} and float for {"price": 10.5} - the same field is one or the other depending on the value it carries, and a strict 'float' fails on every round one. It accepts both now; 'integer' stays strict. --- src/Http/JsonSubset.php | 9 +++++++ src/Http/JsonType.php | 11 +++++--- src/Traits/RestAssertionsTrait.php | 13 ++++++++++ tests/Traits/RestAssertionsTraitTest.php | 33 ++++++++++++++++++++++++ tests/Unit/Http/JsonSubsetTest.php | 20 ++++++++++++-- tests/Unit/Http/JsonTypeTest.php | 11 ++++++-- 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/Http/JsonSubset.php b/src/Http/JsonSubset.php index 0f909f4..a49546e 100644 --- a/src/Http/JsonSubset.php +++ b/src/Http/JsonSubset.php @@ -23,6 +23,11 @@ * Keys and list elements present in the actual document but absent from the * expected one are ignored, so a fragment can be asserted against a full * envelope. List elements match in any order. + * + * The one place this stops being a subset is the empty list: ['data' => []] + * requires data to be empty rather than matching any data at all. Read as a + * pure subset it would match everything, which is never what the caller meant + * and asserts nothing. */ final class JsonSubset { @@ -55,6 +60,10 @@ public static function contains(mixed $actual, mixed $expected): bool */ private static function containsList(array $actual, array $expected): bool { + if ([] === $expected) { + return [] === $actual; + } + foreach ($expected as $item) { $found = false; foreach ($actual as $candidate) { diff --git a/src/Http/JsonType.php b/src/Http/JsonType.php index 1514f03..89b5e2c 100644 --- a/src/Http/JsonType.php +++ b/src/Http/JsonType.php @@ -29,11 +29,16 @@ * Validates a decoded JSON document against a map of type expectations. * * Keys absent from the type map are ignored, so a map can describe the part of - * an envelope a test cares about while the document carries more. Types are - * strict: an int does not satisfy 'float'. + * an envelope a test cares about while the document carries more. * * Supported specs: 'array', 'boolean', 'float', 'integer', 'null', 'string', * optionally suffixed with ':date', and joined with '|' to form a union. + * + * 'float' accepts an int as well as a float. JSON has a single number type, so + * json_decode() hands back int(10) for {"price": 10} and float(10.5) for + * {"price": 10.5} - the same field is one or the other depending on the value + * it happens to carry. A strict 'float' would fail on every whole number. + * 'integer' stays strict, so it still rejects 10.5. */ final class JsonType { @@ -97,7 +102,7 @@ private static function matchesSingle(mixed $value, string $alternative): bool $matches = match ($alternative) { 'array' => is_array($value), 'boolean' => is_bool($value), - 'float' => is_float($value), + 'float' => is_float($value) || is_int($value), 'integer' => is_int($value), 'null' => null === $value, 'string' => is_string($value), diff --git a/src/Traits/RestAssertionsTrait.php b/src/Traits/RestAssertionsTrait.php index e0ec122..4d6d127 100644 --- a/src/Traits/RestAssertionsTrait.php +++ b/src/Traits/RestAssertionsTrait.php @@ -92,6 +92,8 @@ public function assertResponseContains(string $text): void */ public function assertResponseContainsJson(array $json): void { + $this->assertNotSame([], $json, $this->emptyFragmentMessage()); + $this->assertTrue( JsonSubset::contains($this->decodedResponse(), $json), 'Failed asserting that the response contains the given JSON fragment. Response: ' @@ -139,6 +141,8 @@ public function assertResponseNotContains(string $text): void */ public function assertResponseNotContainsJson(array $json): void { + $this->assertNotSame([], $json, $this->emptyFragmentMessage()); + $this->assertFalse( JsonSubset::contains($this->decodedResponse(), $json), 'Failed asserting that the response does not contain the given JSON fragment.' @@ -178,4 +182,13 @@ private function decodedResponse(): array return is_array($decoded) ? $decoded : []; } + + /** + * Traits cannot declare constants until PHP 8.2, so this stands in for one. + */ + private function emptyFragmentMessage(): string + { + return 'An empty fragment asserts nothing about the response; ' + . 'pass the fragment you mean to assert.'; + } } diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php index c0e36d6..9e5a405 100644 --- a/tests/Traits/RestAssertionsTraitTest.php +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -100,6 +100,19 @@ public function testAssertResponseContainsFailsWhenTheTextIsAbsent(): void $this->assertResponseContains('"b"'); } + /** + * ['data' => []] must assert that data IS empty, not merely that the key is + * there - the vacuous reading is the one a caller never means. + */ + public function testAssertResponseContainsJsonEmptyListMeansEmpty(): void + { + $this->respondWith('{"data":[{"id":1}]}'); + + $this->expectException(AssertionFailedError::class); + + $this->assertResponseContainsJson(['data' => []]); + } + /** * The failure message carries the response body - without it a failing * fragment assertion says nothing about what actually came back. @@ -122,6 +135,16 @@ public function testAssertResponseContainsJsonMatchesFragment(): void $this->assertResponseNotContainsJson(['data' => [['name' => 'Other']]]); } + public function testAssertResponseContainsJsonRejectsAnEmptyFragment(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/empty fragment asserts nothing/'); + + $this->assertResponseContainsJson([]); + } + public function testAssertResponseEqualsFailsOnADifferentBody(): void { $this->respondWith('{"a":1}'); @@ -200,6 +223,16 @@ public function testAssertResponseNotContainsJsonFailsWhenTheFragmentMatches(): $this->assertResponseNotContainsJson(['data' => [['name' => 'Acme']]]); } + public function testAssertResponseNotContainsJsonRejectsAnEmptyFragment(): void + { + $this->respondWith('{"a":1}'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/empty fragment asserts nothing/'); + + $this->assertResponseNotContainsJson([]); + } + public function testAssertResponseNotMatchesJsonType(): void { $this->respondWith('{"meta":{"hash":1}}'); diff --git a/tests/Unit/Http/JsonSubsetTest.php b/tests/Unit/Http/JsonSubsetTest.php index 91c2737..2460929 100644 --- a/tests/Unit/Http/JsonSubsetTest.php +++ b/tests/Unit/Http/JsonSubsetTest.php @@ -23,9 +23,25 @@ public function testEmptyExpectedListAgainstEmptyActualList(): void $this->assertTrue(JsonSubset::contains(['data' => []], ['data' => []])); } - public function testEmptyExpectedMatchesAnything(): void + /** + * The trap this guards: ['data' => []] must mean "data is empty", not + * "data exists". rest-api's seeSuccessJsonResponse() defaults straight + * into this shape. + */ + public function testEmptyExpectedListRequiresEmptyActualList(): void { - $this->assertTrue(JsonSubset::contains(['a' => 1], [])); + $this->assertFalse(JsonSubset::contains(['data' => [['id' => 1]]], ['data' => []])); + $this->assertTrue(JsonSubset::contains(['data' => []], ['data' => []])); + } + + /** + * An empty expected list means "empty", not "anything" - as a pure subset + * it would match every document and assert nothing. + */ + public function testEmptyExpectedRequiresEmptyActual(): void + { + $this->assertFalse(JsonSubset::contains(['a' => 1], [])); + $this->assertTrue(JsonSubset::contains([], [])); } public function testExpectedArrayAgainstScalarActualFails(): void diff --git a/tests/Unit/Http/JsonTypeTest.php b/tests/Unit/Http/JsonTypeTest.php index da53104..fe229f5 100644 --- a/tests/Unit/Http/JsonTypeTest.php +++ b/tests/Unit/Http/JsonTypeTest.php @@ -36,10 +36,17 @@ public function testEmptyTypeMapMatchesAnything(): void $this->assertNull(JsonType::match(['a' => 1], [])); } - public function testIntegerIsNotAFloat(): void + /** + * JSON has one number type: {"price": 10} decodes to int and + * {"price": 10.5} to float, so 'float' has to accept both or it fails on + * every whole number. 'integer' stays strict. + */ + public function testFloatAcceptsWholeNumbersButIntegerStaysStrict(): void { - $this->assertNotNull(JsonType::match(['a' => 1], ['a' => 'float'])); + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'float'])); $this->assertNull(JsonType::match(['a' => 1.5], ['a' => 'float'])); + $this->assertNotNull(JsonType::match(['a' => 1.5], ['a' => 'integer'])); + $this->assertNull(JsonType::match(['a' => 1], ['a' => 'integer'])); } /** From a1b70c5c4a0856cf6aff13cab61ed9fba8be34ed Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 12:47:40 -0500 Subject: [PATCH 17/21] [#19] - updating changelog for the review fixes Two entries had gone stale against the code: JsonType claimed strict types (float now accepts a whole number) and JsonSubset claimed absent list elements are always ignored (an empty expected list now means empty). File uploads and useRestBaseUrl() were added after the entries were written and were missing. --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a752588..26934f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ ### Added -- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. [#19](https://github.com/phalcon/talon/issues/19) +- Added a REST/JSON test surface: `Phalcon\Talon\Traits\RestTrait` (the full verb set, file uploads, request headers that persist across requests, `amBearerAuthenticated()`/`amHttpAuthenticated()`, redirect control, and response grabbers), `Phalcon\Talon\Traits\RestAssertionsTrait` (status, range, body, header, and JSON assertions), and `Phalcon\Talon\PHPUnit\AbstractRestTestCase` which composes both. A raw string body is sent as `application/json` unless a content type is set, and `$files` takes either a plain path or the `$_FILES` shape. [#19](https://github.com/phalcon/talon/issues/19) - Added `Phalcon\Talon\Http\HttpCode` - HTTP status constants plus `getDescription()`, which returns the `404 (Not Found)` form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test, so that asserting an application's emitted status string against it actually asserts something. [#19](https://github.com/phalcon/talon/issues/19) -- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored. Types are strict: an int does not satisfy `float`. [#19](https://github.com/phalcon/talon/issues/19) -- Added `Phalcon\Talon\Http\JsonSubset` - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. [#19](https://github.com/phalcon/talon/issues/19) -- Added `TALON_REST_URL` to `Settings::fromEnv()`, readable via `Settings::get('rest_url')` and defaulting to `http://127.0.0.1:8080`. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonType` - validates a decoded JSON document against a map of type expectations (`string`, `integer`, `float`, `boolean`, `array`, `null`, the `:date` filter, `|` unions, and nested maps). Keys absent from the map are ignored, so a map can describe just the part of an envelope a test cares about. `float` accepts a whole number too - JSON has a single number type, so `{"price": 10}` decodes to an int and a strict `float` would fail on every round value. `integer` stays strict. [#19](https://github.com/phalcon/talon/issues/19) +- Added `Phalcon\Talon\Http\JsonSubset` - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. An empty expected list is the exception: `['data' => []]` asserts that `data` is empty rather than matching any `data` at all. [#19](https://github.com/phalcon/talon/issues/19) +- Added `TALON_REST_URL` to `Settings::fromEnv()`, readable via `Settings::get('rest_url')` and defaulting to `http://127.0.0.1:8080`. It is resolved through Talon's shared `Settings` and so is the same for every test in a run; `RestTrait::useRestBaseUrl()` points an individual test somewhere else. [#19](https://github.com/phalcon/talon/issues/19) ### Fixed From 688375d57351166be0948bcf2108670bef088107 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 14:17:58 -0500 Subject: [PATCH 18/21] [#19] - splitting JsonType::match to cut its cognitive complexity match() scored 17 against Sonar's rules, over the default threshold of 15. It was traversing the map, checking key presence, dispatching leaf-vs-nested and formatting error text all at once, and the nesting penalty did most of the damage - two branches cost +3 each purely for sitting two levels deep. The tell was the guard reading !is_string($expected) || !self::matchesSpec($value, $expected) which collapsed two distinct failures into one branch, then re-evaluated is_string() inside the sprintf to work out which one it had caught. matchValue() keeps them apart so each reports itself. path() and typeError() take the remaining inline work out of the loop, and the continue goes with it. match() is now 6; nothing in the file is above it. Behaviour is unchanged - the existing tests carried the refactor untouched. Mutation testing also showed 'float' accepting a string, because negating both operands of is_float() || is_int() is unobservable without a non-number case. --- src/Http/JsonType.php | 59 ++++++++++++++++++++++---------- tests/Unit/Http/JsonTypeTest.php | 2 ++ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/Http/JsonType.php b/src/Http/JsonType.php index 89b5e2c..a9ab42a 100644 --- a/src/Http/JsonType.php +++ b/src/Http/JsonType.php @@ -54,30 +54,15 @@ public static function match(mixed $actual, array $types, string $path = ''): ?s } foreach ($types as $key => $expected) { - $current = '' === $path ? (string) $key : $path . '.' . $key; + $current = self::path($path, $key); if (!array_key_exists($key, $actual)) { return sprintf("Key '%s' is missing", $current); } - $value = $actual[$key]; - - if (is_array($expected)) { - $error = self::match($value, $expected, $current); - if (null !== $error) { - return $error; - } - - continue; - } - - if (!is_string($expected) || !self::matchesSpec($value, $expected)) { - return sprintf( - "Key '%s' expected '%s', got '%s'", - $current, - is_string($expected) ? $expected : get_debug_type($expected), - get_debug_type($value) - ); + $error = self::matchValue($actual[$key], $expected, $current); + if (null !== $error) { + return $error; } } @@ -126,4 +111,40 @@ private static function matchesSpec(mixed $value, string $spec): bool return false; } + + /** + * Checks one key's value against its spec: a nested map recurses, anything + * else is a leaf. Keeping the two failure modes apart - the spec is not a + * string, or the value does not satisfy it - is what lets each report + * itself, rather than one branch re-deriving which case it caught. + */ + private static function matchValue(mixed $value, mixed $expected, string $path): ?string + { + if (is_array($expected)) { + return self::match($value, $expected, $path); + } + + if (!is_string($expected)) { + return self::typeError($path, get_debug_type($expected), $value); + } + + if (!self::matchesSpec($value, $expected)) { + return self::typeError($path, $expected, $value); + } + + return null; + } + + /** + * @param array-key $key + */ + private static function path(string $path, mixed $key): string + { + return '' === $path ? (string) $key : $path . '.' . $key; + } + + private static function typeError(string $path, string $expected, mixed $value): string + { + return sprintf("Key '%s' expected '%s', got '%s'", $path, $expected, get_debug_type($value)); + } } diff --git a/tests/Unit/Http/JsonTypeTest.php b/tests/Unit/Http/JsonTypeTest.php index fe229f5..837f94a 100644 --- a/tests/Unit/Http/JsonTypeTest.php +++ b/tests/Unit/Http/JsonTypeTest.php @@ -47,6 +47,8 @@ public function testFloatAcceptsWholeNumbersButIntegerStaysStrict(): void $this->assertNull(JsonType::match(['a' => 1.5], ['a' => 'float'])); $this->assertNotNull(JsonType::match(['a' => 1.5], ['a' => 'integer'])); $this->assertNull(JsonType::match(['a' => 1], ['a' => 'integer'])); + $this->assertNotNull(JsonType::match(['a' => 'x'], ['a' => 'float'])); + $this->assertNotNull(JsonType::match(['a' => null], ['a' => 'float'])); } /** From 536724bcd0b9cfddb249c02e18938fcf0bb10cf6 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 14:17:59 -0500 Subject: [PATCH 19/21] [#19] - closing the mutation gaps in the REST tests The upload tests uploaded __FILE__, so the multipart body embedded the test's own source - which contains every string the assertions look for. They passed whatever was actually sent, including when only the first of several files was. They now post a fixture whose contents cannot collide, and assert on the Content-Disposition filenames. The form-body test asserted 'username' and 'sarah' appear in the body; both appear in a JSON body too, so it could not tell the form path from the JSON one. It pins the urlencoded body now. useRestBaseUrl() had no caller outside the class hierarchy, so nothing held its visibility. The empty-fragment message was asserted by a pattern matching only its first half; it is one literal now, with nothing to reorder or drop. --- src/Traits/RestAssertionsTrait.php | 3 +- tests/Fakes/Rest/upload.txt | 1 + tests/Traits/RestAssertionsTraitTest.php | 4 +-- tests/Traits/RestPublicApiTest.php | 4 +++ tests/Traits/RestTraitTest.php | 37 ++++++++++++++++++++---- 5 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 tests/Fakes/Rest/upload.txt diff --git a/src/Traits/RestAssertionsTrait.php b/src/Traits/RestAssertionsTrait.php index 4d6d127..c9ddbbb 100644 --- a/src/Traits/RestAssertionsTrait.php +++ b/src/Traits/RestAssertionsTrait.php @@ -188,7 +188,6 @@ private function decodedResponse(): array */ private function emptyFragmentMessage(): string { - return 'An empty fragment asserts nothing about the response; ' - . 'pass the fragment you mean to assert.'; + return 'An empty fragment asserts nothing; pass the fragment you mean to assert.'; } } diff --git a/tests/Fakes/Rest/upload.txt b/tests/Fakes/Rest/upload.txt new file mode 100644 index 0000000..2a4eb7e --- /dev/null +++ b/tests/Fakes/Rest/upload.txt @@ -0,0 +1 @@ +talon upload fixture diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php index 9e5a405..e389d2d 100644 --- a/tests/Traits/RestAssertionsTraitTest.php +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -140,7 +140,7 @@ public function testAssertResponseContainsJsonRejectsAnEmptyFragment(): void $this->respondWith('{"a":1}'); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/empty fragment asserts nothing/'); + $this->expectExceptionMessageMatches('/An empty fragment asserts nothing; pass the fragment you mean to assert/'); $this->assertResponseContainsJson([]); } @@ -228,7 +228,7 @@ public function testAssertResponseNotContainsJsonRejectsAnEmptyFragment(): void $this->respondWith('{"a":1}'); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/empty fragment asserts nothing/'); + $this->expectExceptionMessageMatches('/An empty fragment asserts nothing; pass the fragment you mean to assert/'); $this->assertResponseNotContainsJson([]); } diff --git a/tests/Traits/RestPublicApiTest.php b/tests/Traits/RestPublicApiTest.php index 3372fdb..a261411 100644 --- a/tests/Traits/RestPublicApiTest.php +++ b/tests/Traits/RestPublicApiTest.php @@ -81,6 +81,10 @@ public function testDefaultSeamsStayReachableFromASubclass(): void $this->assertSame('http://127.0.0.1:8080', $subject->rawBaseUrl()); $this->assertNull($subject->rawHttpClient()); + + $subject->useRestBaseUrl('http://injected.test:9000'); + + $this->assertSame('http://injected.test:9000', $subject->rawBaseUrl()); } public function testRangeAssertionsAreCallableFromOutsideTheHierarchy(): void diff --git a/tests/Traits/RestTraitTest.php b/tests/Traits/RestTraitTest.php index a146e82..bc1c7c3 100644 --- a/tests/Traits/RestTraitTest.php +++ b/tests/Traits/RestTraitTest.php @@ -31,6 +31,13 @@ final class RestTraitTest extends AbstractUnitTestCase { use RestTrait; + /** + * Uploads use a fixture rather than __FILE__: a multipart body embeds the + * file's contents, so uploading the test itself makes it "contain" every + * string this file asserts on, and the assertions pass whatever was sent. + */ + private const FIXTURE = __DIR__ . '/../Fakes/Rest/upload.txt'; + /** * @var array, body: string}> */ @@ -258,8 +265,10 @@ public function testSendPostSendsFormParametersByDefault(): void $this->sendPost('/login', ['username' => 'sarah']); $this->assertSame('POST', $this->requests[0]['method']); - $this->assertStringContainsString('username', $this->requests[0]['body']); - $this->assertStringContainsString('sarah', $this->requests[0]['body']); + // Asserted as urlencoded rather than by field name: 'username' and + // 'sarah' both appear in a JSON body too, so a looser check cannot tell + // the form path from the JSON one. + $this->assertSame('username=sarah', $this->requests[0]['body']); } public function testSendPostSerializesJsonWhenContentTypeIsJson(): void @@ -280,20 +289,36 @@ public function testSendPostSerializesJsonWhenContentTypeIsJson(): void */ public function testSendPostUploadsAFileGivenAPlainPath(): void { - $this->sendPost('/upload', ['name' => 'Acme'], ['doc' => __FILE__]); + $this->sendPost('/upload', ['name' => 'Acme'], ['doc' => self::FIXTURE]); $this->assertSame('POST', $this->requests[0]['method']); $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); - $this->assertStringContainsString('RestTraitTest.php', $this->requests[0]['body']); + $this->assertStringContainsString('filename="upload.txt"', $this->requests[0]['body']); + $this->assertStringContainsString('talon upload fixture', $this->requests[0]['body']); $this->assertStringContainsString('Acme', $this->requests[0]['body']); } public function testSendPostUploadsAFileGivenTheFilesShape(): void { - $this->sendPost('/upload', [], ['doc' => ['tmp_name' => __FILE__, 'name' => 'custom.php']]); + $this->sendPost('/upload', [], ['doc' => ['tmp_name' => self::FIXTURE, 'name' => 'custom.txt']]); $this->assertStringContainsString('multipart/form-data', $this->contentTypeOf(0)); - $this->assertStringContainsString('custom.php', $this->requests[0]['body']); + $this->assertStringContainsString('filename="custom.txt"', $this->requests[0]['body']); + } + + /** + * More than one file, because a single-file test cannot notice a + * normalisation that quietly keeps only the first. + */ + public function testSendPostUploadsSeveralFiles(): void + { + $this->sendPost('/upload', [], [ + 'first' => self::FIXTURE, + 'second' => ['tmp_name' => self::FIXTURE, 'name' => 'second.txt'], + ]); + + $this->assertStringContainsString('filename="upload.txt"', $this->requests[0]['body']); + $this->assertStringContainsString('filename="second.txt"', $this->requests[0]['body']); } public function testSendUppercasesTheMethod(): void From 0bba074503378519006c43499519d18c45f73d7a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 14:37:20 -0500 Subject: [PATCH 20/21] phpcs --- tests/Traits/RestAssertionsTraitTest.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php index e389d2d..12c3f13 100644 --- a/tests/Traits/RestAssertionsTraitTest.php +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -140,7 +140,9 @@ public function testAssertResponseContainsJsonRejectsAnEmptyFragment(): void $this->respondWith('{"a":1}'); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/An empty fragment asserts nothing; pass the fragment you mean to assert/'); + $this->expectExceptionMessageMatches( + '/An empty fragment asserts nothing; pass the fragment you mean to assert/' + ); $this->assertResponseContainsJson([]); } @@ -183,7 +185,9 @@ public function testAssertResponseIsJsonFailureNamesTheResponse(): void public function testAssertResponseMatchesJsonType(): void { $this->respondWith( - '{"jsonapi":{"version":"1.0"},"meta":{"timestamp":"2026-07-15T10:30:00+00:00","hash":"abc"}}' + '{"jsonapi":{"version":"1.0"},' + . '"meta":{"timestamp":"2026-07-15T10:30:00+00:00",' + . '"hash":"abc"}}' ); $this->assertResponseMatchesJsonType([ @@ -228,7 +232,9 @@ public function testAssertResponseNotContainsJsonRejectsAnEmptyFragment(): void $this->respondWith('{"a":1}'); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessageMatches('/An empty fragment asserts nothing; pass the fragment you mean to assert/'); + $this->expectExceptionMessageMatches( + '/An empty fragment asserts nothing; pass the fragment you mean to assert/' + ); $this->assertResponseNotContainsJson([]); } @@ -309,8 +315,11 @@ public function testNoHttpHeaderFailsWhenTheValueMatches(): void * * @dataProvider providerRangeBoundaries */ - public function testRangeAssertionBoundaries(int $code, string $method, bool $shouldPass): void - { + public function testRangeAssertionBoundaries( + int $code, + string $method, + bool $shouldPass + ): void { $this->respondWith('{}', $code); if (!$shouldPass) { From b53de38cb3e4376516e3473de80d5db01dcfab89 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 15 Jul 2026 14:43:00 -0500 Subject: [PATCH 21/21] phpcs --- tests/Traits/RestAssertionsTraitTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Traits/RestAssertionsTraitTest.php b/tests/Traits/RestAssertionsTraitTest.php index 12c3f13..85466ff 100644 --- a/tests/Traits/RestAssertionsTraitTest.php +++ b/tests/Traits/RestAssertionsTraitTest.php @@ -142,7 +142,7 @@ public function testAssertResponseContainsJsonRejectsAnEmptyFragment(): void $this->expectException(AssertionFailedError::class); $this->expectExceptionMessageMatches( '/An empty fragment asserts nothing; pass the fragment you mean to assert/' - ); + ); $this->assertResponseContainsJson([]); } @@ -234,7 +234,7 @@ public function testAssertResponseNotContainsJsonRejectsAnEmptyFragment(): void $this->expectException(AssertionFailedError::class); $this->expectExceptionMessageMatches( '/An empty fragment asserts nothing; pass the fragment you mean to assert/' - ); + ); $this->assertResponseNotContainsJson([]); } @@ -316,8 +316,8 @@ public function testNoHttpHeaderFailsWhenTheValueMatches(): void * @dataProvider providerRangeBoundaries */ public function testRangeAssertionBoundaries( - int $code, - string $method, + int $code, + string $method, bool $shouldPass ): void { $this->respondWith('{}', $code);