diff --git a/config.default.ini b/config.default.ini index 87b1ea47..d45eb5f0 100644 --- a/config.default.ini +++ b/config.default.ini @@ -19,7 +19,7 @@ error_page_dir=page/_error router_file=router.php router_class=AppRouter redirect_response_code=307 -default_content_type=application/json +default_content_type=text/html [view] component_directory=page/_component @@ -29,6 +29,7 @@ partial_directory=page/_partial log_all_requests=true log_static_requests=false log_404_to_error_log=false +log_not_modified=false log_redirects=false debug_to_javascript=true stderr_level=ERROR diff --git a/src/Application.php b/src/Application.php index eb009fee..33213c54 100644 --- a/src/Application.php +++ b/src/Application.php @@ -24,6 +24,8 @@ use GT\Http\RequestFactory; use GT\Http\Response; use GT\Http\Stream; +use GT\Http\StatusCode; +use GT\Csrf\HTMLDocumentProtector; use GT\ProtectedGlobal\Protection; use Psr\Http\Message\ServerRequestInterface; @@ -186,11 +188,16 @@ private function handleThrowable(Throwable $throwable):?Response { return null; } - $this->logError($throwable); $errorStatus = $throwable instanceof ResponseStatusException ? $throwable->getHttpCode() : 500; + if($errorStatus === StatusCode::NOT_MODIFIED) { + return new Response(StatusCode::NOT_MODIFIED, request: $this->request); + } + + $this->logError($throwable); + $this->dispatcher = $this->dispatcherFactory->create( $this->config, $this->request, @@ -494,7 +501,7 @@ private function buildLogContext( string $remoteAddress = "", ):array { $postArray = is_array($postBody) - ? $postBody + ? $this->filterLoggedPostBody($postBody) : []; $context = [ @@ -555,8 +562,20 @@ private function shouldLogRequest(Response $response):bool { if($statusCode === 404) { return false; } + if($statusCode === StatusCode::NOT_MODIFIED) { + return $this->config->getBool("logger.log_not_modified"); + } - return $statusCode < 300 || $statusCode >= 400; + return true; + } + + /** + * @param array $postBody + * @return array + */ + private function filterLoggedPostBody(array $postBody):array { + unset($postBody[HTMLDocumentProtector::TOKEN_NAME]); + return $postBody; } /** @param array $context */ diff --git a/src/Dispatch/Dispatcher.php b/src/Dispatch/Dispatcher.php index 110edc90..0407e1e1 100644 --- a/src/Dispatch/Dispatcher.php +++ b/src/Dispatch/Dispatcher.php @@ -274,6 +274,10 @@ public function generateErrorResponse(Throwable $throwable):Response { $errorStatusCode = $throwable->getHttpCode(); } + if($errorStatusCode === StatusCode::NOT_MODIFIED) { + return $this->response->withStatus(StatusCode::NOT_MODIFIED); + } + if(!$this->viewAssembly->containsDistinctFile()) { Log::warning( "Error page template not found for HTTP " . $errorStatusCode, diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php index 571e325a..c0ed1124 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -1,16 +1,17 @@ expects(self::once()) @@ -398,13 +407,210 @@ public function testStart_rebuildsDispatcherWithErrorStatusAndSessionInit():void self::assertSame($sessionInit, $createCalls[1]["sessionInit"]); } - public function testStart_fallsBackToBasicErrorResponseWhenErrorPageRenderingFails():void { + public function testStart_returnsNotModifiedWithoutErrorDispatcher():void { $this->resetApplicationLoggerState(); LogConfig::addHandler(new TestLogHandler()); $this->setApplicationLoggerConfigured(true); $config = $this->createTestConfig(["app.error_script" => ""]); $requestFactory = self::createStub(RequestFactory::class); + $requestFactory->method("createServerRequestFromGlobalState") + ->willReturn($this->createServerRequest("/live")); + + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once()) + ->method("generateResponse") + ->willThrowException(new HttpNotModified()); + $dispatcher->expects(self::never()) + ->method("generateErrorResponse"); + + $dispatcherFactory = self::createMock(DispatcherFactory::class); + $dispatcherFactory->expects(self::once()) + ->method("create") + ->willReturn($dispatcher); + + $sut = new Application( + config: $config, + requestFactory: $requestFactory, + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + + $sut->start(); + + self::assertCount(0, TestLogHandler::$records); + } + + public function testHandleThrowable_returnsNotModifiedResponseWithoutLoggingError():void { + $this->resetApplicationLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $request = $this->createServerRequest("/cached"); + $sut = new Application( + config: $this->createTestConfig(["app.error_script" => ""]), + requestFactory: $this->createRequestFactory($request), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + ); + $this->setPrivateProperty($sut, "request", $request); + + $response = $this->invokePrivateMethod($sut, "handleThrowable", new HttpNotModified()); + + self::assertInstanceOf(Response::class, $response); + self::assertSame(StatusCode::NOT_MODIFIED, $response->getStatusCode()); + self::assertSame([], TestLogHandler::$records); + } + + public function testHandleThrowable_runsErrorScriptWithRestoredGlobalsAndReturnsNull():void { + $php = <<<'PHP' + createTestConfig([ + "app.error_script" => $tmpFile, + ]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + globals: [ + "_SERVER" => [], + "_FILES" => [], + "_GET" => [], + "_POST" => ["message" => "restored"], + "_ENV" => [], + "_COOKIE" => [], + ], + ); + + $_POST = ["message" => "wrong"]; + $GLOBALS["POST"] = ["message" => "wrong"]; + + ob_start(); + $response = $this->invokePrivateMethod($sut, "handleThrowable", new Exception("boom")); + $output = ob_get_clean(); + unlink($tmpFile); + + self::assertNull($response); + self::assertSame("restored|restored", $output); + } + + public function testHandleThrowable_rebuildsDispatcherAndReturnsGeneratedErrorResponse():void { + $this->resetApplicationLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig(["app.error_script" => ""]); + $request = $this->createServerRequest("/broken"); + $sessionInit = self::createStub(SessionInit::class); + $throwable = new Exception("page failed"); + $errorResponse = $this->createResponse(500, "error"); + + $firstDispatcher = self::createMock(Dispatcher::class); + $firstDispatcher->expects(self::once()) + ->method("getSessionInit") + ->willReturn($sessionInit); + + $secondDispatcher = self::createMock(Dispatcher::class); + $secondDispatcher->expects(self::once()) + ->method("generateErrorResponse") + ->with(self::identicalTo($throwable)) + ->willReturn($errorResponse); + + $dispatcherFactory = self::createMock(DispatcherFactory::class); + $dispatcherFactory->expects(self::once()) + ->method("create") + ->with( + self::identicalTo($config), + self::identicalTo($request), + self::isArray(), + self::isInstanceOf(Closure::class), + 500, + self::identicalTo($sessionInit), + ) + ->willReturn($secondDispatcher); + + $sut = new Application( + config: $config, + requestFactory: $this->createRequestFactory($request), + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + $this->setPrivateProperty($sut, "request", $request); + $this->setPrivateProperty($sut, "dispatcher", $firstDispatcher); + + $response = $this->invokePrivateMethod($sut, "handleThrowable", $throwable); + + self::assertSame($errorResponse, $response); + self::assertCount(1, TestLogHandler::$records); + self::assertStringContainsString("page failed", TestLogHandler::$records[0]["message"]); + } + + public function testHandleThrowable_fallsBackToBasicErrorResponseWhenErrorResponseFails():void { + $this->resetApplicationLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $request = $this->createServerRequest("/broken-error"); + $throwable = new Exception("page failed"); + $innerThrowable = new Exception("error page failed"); + $fallbackResponse = $this->createResponse(500, "fallback"); + + $firstDispatcher = self::createMock(Dispatcher::class); + $firstDispatcher->expects(self::once()) + ->method("getSessionInit") + ->willReturn(null); + + $secondDispatcher = self::createMock(Dispatcher::class); + $secondDispatcher->expects(self::once()) + ->method("generateErrorResponse") + ->with(self::identicalTo($throwable)) + ->willThrowException($innerThrowable); + $secondDispatcher->expects(self::once()) + ->method("generateBasicErrorResponse") + ->with( + self::identicalTo($throwable), + self::identicalTo($innerThrowable), + ) + ->willReturn($fallbackResponse); + + $dispatcherFactory = self::createStub(DispatcherFactory::class); + $dispatcherFactory->method("create") + ->willReturn($secondDispatcher); + + $sut = new Application( + config: $this->createTestConfig(["app.error_script" => ""]), + requestFactory: $this->createRequestFactory($request), + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + $this->setPrivateProperty($sut, "request", $request); + $this->setPrivateProperty($sut, "dispatcher", $firstDispatcher); + + $response = $this->invokePrivateMethod($sut, "handleThrowable", $throwable); + + self::assertSame($fallbackResponse, $response); + self::assertCount(2, TestLogHandler::$records); + self::assertStringContainsString("Failed to render framework error response", TestLogHandler::$records[1]["message"]); + self::assertSame("Exception", TestLogHandler::$records[1]["context"]["original_error"]); + self::assertSame("/broken-error", TestLogHandler::$records[1]["context"]["uri"]); + } + + public function testStart_fallsBackToBasicErrorResponseWhenErrorPageRenderingFails():void { + $this->resetApplicationLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig([ + "app.error_script" => "", + "logger.log_all_requests" => false, + ]); + $requestFactory = self::createStub(RequestFactory::class); $requestFactory->method("createServerRequestFromGlobalState") ->willReturn($this->createServerRequest("/broken-error")); @@ -444,7 +650,9 @@ public function testStart_fallsBackToBasicErrorResponseWhenErrorPageRenderingFai globalProtection: self::createStub(Protection::class), ); + ob_start(); $sut->start(); + ob_get_clean(); self::assertCount(2, TestLogHandler::$records); self::assertSame("ERROR", TestLogHandler::$records[0]["level"]); @@ -533,9 +741,13 @@ public function testStart_restoresGlobalsBeforeExecutingErrorScript():void { globals: $globals, ); + $initialOutputBufferLevel = ob_get_level(); ob_start(); $sut->start(); - $output = ob_get_clean(); + $output = ""; + while(ob_get_level() > $initialOutputBufferLevel) { + $output = ob_get_clean() . $output; + } self::assertSame("query|server|query", $output); } @@ -625,6 +837,51 @@ public function testFinish_injectsDebugScriptAndRunsOnlyOnce():void { self::assertSame("Hello", $output); } + public function testBuildLogContext_omitsObjectParsedBodyAndKeepsQueryContext():void { + $sut = new Application( + config: $this->createTestConfig([]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + ); + + $context = $this->invokePrivateMethod( + $sut, + "buildLogContext", + "/api", + ["page" => "2"], + (object)["token" => "should-not-be-logged"], + "10.0.0.5", + ); + + self::assertSame("10.0.0.5:", $context["id"]); + self::assertSame("/api", $context["uri"]); + self::assertSame(["page" => "2"], $context["query"]); + self::assertArrayNotHasKey("post", $context); + } + + public function testBuildLogContext_filtersConfiguredCsrfTokenNameOnly():void { + $sut = new Application( + config: $this->createTestConfig([]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + ); + + $context = $this->invokePrivateMethod( + $sut, + "buildLogContext", + "/send", + [], + [ + HTMLDocumentProtector::TOKEN_NAME => "CSRF_secret", + "token" => "business-token", + ], + ); + + self::assertSame(["token" => "business-token"], $context["post"]); + } + public function testStart_logsAllRequestsWithRequestContext():void { $this->resetApplicationLoggerState(); TestLogHandler::$records = []; @@ -671,6 +928,123 @@ public function testStart_logsAllRequestsWithRequestContext():void { self::assertSame("127.0.0.1:", TestLogHandler::$records[0]["context"]["id"]); } + public function testStart_filtersCsrfTokenFromLoggedPostContext():void { + $this->resetApplicationLoggerState(); + TestLogHandler::$records = []; + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig([ + "logger.log_all_requests" => true, + ]); + $request = $this->createServerRequest( + "/send", + post: [ + "message" => "Hello", + "csrf-token" => "CSRF_secret", + "do" => "send", + ], + ); + $requestFactory = self::createStub(RequestFactory::class); + $requestFactory->method("createServerRequestFromGlobalState") + ->willReturn($request); + + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once()) + ->method("generateResponse") + ->willReturn($this->createResponse(204)); + + $dispatcherFactory = self::createStub(DispatcherFactory::class); + $dispatcherFactory->method("create") + ->willReturn($dispatcher); + + $sut = new Application( + config: $config, + requestFactory: $requestFactory, + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + + $sut->start(); + + self::assertSame([ + "message" => "Hello", + "do" => "send", + ], TestLogHandler::$records[0]["context"]["post"]); + } + + public function testStart_doesNotLogNotModifiedByDefault():void { + $this->resetApplicationLoggerState(); + TestLogHandler::$records = []; + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig([ + "logger.log_all_requests" => true, + "logger.log_not_modified" => false, + ]); + $requestFactory = self::createStub(RequestFactory::class); + $requestFactory->method("createServerRequestFromGlobalState") + ->willReturn($this->createServerRequest("/live")); + + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once()) + ->method("generateResponse") + ->willReturn($this->createResponse(StatusCode::NOT_MODIFIED)); + + $dispatcherFactory = self::createStub(DispatcherFactory::class); + $dispatcherFactory->method("create") + ->willReturn($dispatcher); + + $sut = new Application( + config: $config, + requestFactory: $requestFactory, + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + + $sut->start(); + + self::assertSame([], TestLogHandler::$records); + } + + public function testStart_logsNotModifiedWhenConfigured():void { + $this->resetApplicationLoggerState(); + TestLogHandler::$records = []; + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig([ + "logger.log_all_requests" => true, + "logger.log_not_modified" => true, + ]); + $requestFactory = self::createStub(RequestFactory::class); + $requestFactory->method("createServerRequestFromGlobalState") + ->willReturn($this->createServerRequest("/live")); + + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once()) + ->method("generateResponse") + ->willReturn($this->createResponse(StatusCode::NOT_MODIFIED)); + + $dispatcherFactory = self::createStub(DispatcherFactory::class); + $dispatcherFactory->method("create") + ->willReturn($dispatcher); + + $sut = new Application( + config: $config, + requestFactory: $requestFactory, + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + + $sut->start(); + + self::assertCount(1, TestLogHandler::$records); + self::assertSame("HTTP 304", TestLogHandler::$records[0]["message"]); + self::assertSame("/live", TestLogHandler::$records[0]["context"]["uri"]); + } + public function testStart_logsSlowRequestsAsDebugWithRequestContext():void { $this->resetApplicationLoggerState(); TestLogHandler::$records = []; @@ -794,6 +1168,56 @@ public function testStart_logsRedirectResponsesWhenLogRedirectsIsTrue():void { self::assertSame("https://example.test/redirect-me/", TestLogHandler::$records[0]["context"]["location"]); } + public function testStart_logsRedirectResponsesWhenLogAllRequestsIsTrue():void { + $this->resetApplicationLoggerState(); + TestLogHandler::$records = []; + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $config = $this->createTestConfig([ + "logger.log_all_requests" => true, + "logger.log_redirects" => false, + ]); + $request = $this->createServerRequest( + "/message", + post: [ + "message" => "Hello from Flux", + "csrf-token" => "CSRF_secret", + "do" => "send", + ], + ); + $requestFactory = self::createStub(RequestFactory::class); + $requestFactory->method("createServerRequestFromGlobalState") + ->willReturn($request); + + $dispatcher = self::createMock(Dispatcher::class); + $dispatcher->expects(self::once()) + ->method("generateResponse") + ->willReturn($this->createResponse(303, headers: ["Location" => ["https://example.test/message/"]])); + + $dispatcherFactory = self::createStub(DispatcherFactory::class); + $dispatcherFactory->method("create") + ->willReturn($dispatcher); + + $sut = new Application( + config: $config, + requestFactory: $requestFactory, + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + ); + + $sut->start(); + + self::assertCount(1, TestLogHandler::$records); + self::assertSame("HTTP 303", TestLogHandler::$records[0]["message"]); + self::assertSame("/message", TestLogHandler::$records[0]["context"]["uri"]); + self::assertSame([ + "message" => "Hello from Flux", + "do" => "send", + ], TestLogHandler::$records[0]["context"]["post"]); + self::assertSame("https://example.test/message/", TestLogHandler::$records[0]["context"]["location"]); + } + public function testStart_logsPreDispatchRedirectsWhenLogRedirectsIsTrue():void { $this->resetApplicationLoggerState(); TestLogHandler::$records = []; @@ -836,6 +1260,105 @@ public function testStart_logsPreDispatchRedirectsWhenLogRedirectsIsTrue():void self::assertSame("/new-home", TestLogHandler::$records[0]["context"]["location"]); } + public function testStart_matchesPreDispatchRedirectsUsingDecodedPathWithoutQuery():void { + $this->resetApplicationLoggerState(); + TestLogHandler::$records = []; + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $redirect = self::createMock(Redirect::class); + $redirect->expects(self::once()) + ->method("execute") + ->with("/old page") + ->willReturn(new RedirectUri("/new-page", 302)); + + $dispatcherFactory = self::createMock(DispatcherFactory::class); + $dispatcherFactory->expects(self::never()) + ->method("create"); + + $sut = new Application( + redirect: $redirect, + config: $this->createTestConfig([ + "logger.log_redirects" => true, + ]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: $dispatcherFactory, + globalProtection: self::createStub(Protection::class), + globals: [ + "_SERVER" => [ + "REQUEST_URI" => "/old%20page?ignored=1", + "REMOTE_ADDR" => "127.0.0.1", + ], + "_FILES" => [], + "_GET" => ["ignored" => "1"], + "_POST" => [HTMLDocumentProtector::TOKEN_NAME => "CSRF_secret"], + "_ENV" => [], + "_COOKIE" => [], + ], + ); + + $sut->start(); + + self::assertCount(1, TestLogHandler::$records); + self::assertSame("/old page", TestLogHandler::$records[0]["context"]["uri"]); + self::assertSame(["ignored" => "1"], TestLogHandler::$records[0]["context"]["query"]); + self::assertArrayNotHasKey("post", TestLogHandler::$records[0]["context"]); + self::assertSame("/new-page", TestLogHandler::$records[0]["context"]["location"]); + } + + public function testGetRequestedPath_defaultsToSlashWhenRequestUriHasNoPath():void { + $sut = new Application( + config: $this->createTestConfig([]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + globals: [ + "_SERVER" => ["REQUEST_URI" => "?only=query"], + "_FILES" => [], + "_GET" => [], + "_POST" => [], + "_ENV" => [], + "_COOKIE" => [], + ], + ); + + self::assertSame("/", $this->invokePrivateMethod($sut, "getRequestedPath")); + } + + public function testLogRedirect_doesNothingWhenRedirectLoggingDisabled():void { + $this->resetApplicationLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $this->setApplicationLoggerConfigured(true); + + $sut = new Application( + config: $this->createTestConfig([ + "logger.log_redirects" => false, + ]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + ); + + $this->invokePrivateMethod($sut, "logRedirect", new RedirectUri("/new", 302), "/old"); + + self::assertSame([], TestLogHandler::$records); + } + + public function testShouldLogRequest_doesNotLogNotFoundEvenWhenAllRequestsEnabled():void { + $sut = new Application( + config: $this->createTestConfig([ + "logger.log_all_requests" => true, + ]), + requestFactory: $this->createRequestFactory(), + dispatcherFactory: self::createStub(DispatcherFactory::class), + globalProtection: self::createStub(Protection::class), + ); + + $shouldLog = $this->invokePrivateMethod($sut, "shouldLogRequest", $this->createResponse(404)); + + self::assertFalse($shouldLog); + } + public function testLogError_doesNotLogClientErrors():void { $this->resetApplicationLoggerState(); LogConfig::addHandler(new TestLogHandler()); diff --git a/test/phpunit/DefaultRouterTest.php b/test/phpunit/DefaultRouterTest.php index bc3750a2..01ee5d3b 100644 --- a/test/phpunit/DefaultRouterTest.php +++ b/test/phpunit/DefaultRouterTest.php @@ -146,6 +146,32 @@ public function testRoute_pageRequest_withDirectAndIndexView_throwsAmbiguousPath $sut->route($request); } + public function testRoute_errorStatusUsesErrorPageInsteadOfRequestPath():void { + mkdir($this->tmpDir . "/page/_error", recursive: true); + file_put_contents($this->tmpDir . "/page/_error/404.html", "
not found
"); + file_put_contents($this->tmpDir . "/page/_error/404.php", "tmpDir . "/page/missing.html", "
wrong page
"); + + chdir($this->tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("text/html"); + $request->method("getUri")->willReturn(new Uri("https://example.test/missing")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html"), errorStatus: 404); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + $sut->route($request); + + self::assertSame(HTMLView::class, $sut->getViewClass()); + self::assertSame(["page/_error/404.php"], iterator_to_array($sut->getLogicAssembly())); + self::assertSame(["page/_error/404.html"], iterator_to_array($sut->getViewAssembly())); + } + public function testRoute_apiRequest_withLogicOnly_usesJsonView():void { mkdir($this->tmpDir . "/api", recursive: true); file_put_contents($this->tmpDir . "/api/hello.php", "getViewAssembly())); } + public function testRoute_apiRequest_withXmlView_addsViewAssembly():void { + mkdir($this->tmpDir . "/api/report", recursive: true); + file_put_contents($this->tmpDir . "/api/report/index.php", "tmpDir . "/api/report/index.xml", ""); + + chdir($this->tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("application/xml"); + $request->method("getUri")->willReturn(new Uri("https://example.test/report")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html")); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + $sut->route($request); + + self::assertSame(JSONView::class, $sut->getViewClass()); + self::assertSame(["api/report/index.php"], iterator_to_array($sut->getLogicAssembly())); + self::assertSame(["api/report/index.xml"], iterator_to_array($sut->getViewAssembly())); + } + + public function testRoute_apiRequest_withDirectAndIndexLogic_throwsAmbiguousPathException():void { + mkdir($this->tmpDir . "/api/users", recursive: true); + file_put_contents($this->tmpDir . "/api/users.php", "tmpDir . "/api/users/index.php", "tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("application/json"); + $request->method("getUri")->willReturn(new Uri("https://example.test/users")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html")); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + + $this->expectException(AmbiguousPathException::class); + $this->expectExceptionMessage( + "Ambiguous route for '/users': both 'api/users.php' and " + . "'api/users/index.php' match." + ); + $sut->route($request); + } + + public function testRoute_pageRequest_ignoresDynamicFilesWhenConcreteFileExists():void { + mkdir($this->tmpDir . "/page/article", recursive: true); + file_put_contents($this->tmpDir . "/page/article/read.html", "
article
"); + file_put_contents($this->tmpDir . "/page/article/@slug.html", "
dynamic article
"); + + chdir($this->tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("text/html"); + $request->method("getUri")->willReturn(new Uri("https://example.test/article/read")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html")); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + $sut->route($request); + + self::assertSame(["page/article/read.html"], iterator_to_array($sut->getViewAssembly())); + } + private function removeDirectory(string $dir):void { if(!is_dir($dir)) { return; diff --git a/test/phpunit/Dispatch/DispatcherTest.php b/test/phpunit/Dispatch/DispatcherTest.php index 4028f63f..1e2d8dca 100644 --- a/test/phpunit/Dispatch/DispatcherTest.php +++ b/test/phpunit/Dispatch/DispatcherTest.php @@ -18,6 +18,7 @@ use GT\Http\Request; use GT\Http\Response; use GT\Http\ResponseStatusException\ClientError\HttpNotFound; +use GT\Http\ResponseStatusException\Redirection\HttpNotModified; use GT\Http\ResponseStatusException\ResponseStatusException; use GT\Http\ServerInfo; use GT\Http\StatusCode; @@ -596,6 +597,17 @@ public function testGenerateErrorResponse_throwsWhenNoErrorViewExists():void { self::assertSame("/page/", TestLogHandler::$records[0]["context"]["uri"]); } + public function testGenerateErrorResponse_returnsNotModifiedWithoutErrorView():void { + $this->resetLoggerState(); + LogConfig::addHandler(new TestLogHandler()); + $sut = $this->createDispatcher(); + + $response = $sut->generateErrorResponse(new HttpNotModified()); + + self::assertSame(StatusCode::NOT_MODIFIED, $response->getStatusCode()); + self::assertCount(0, TestLogHandler::$records); + } + public function testGenerateErrorResponse_usesThrowableStatusAndReturnsResponse():void { $viewModel = new HTMLDocument('
');
 		$processor = $this->createMock(ViewModelProcessor::class);