From fcf0353153d82367720fe8b622de562200751cd9 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 17 Jul 2026 16:48:24 +0100 Subject: [PATCH 1/2] tidy: improve request logging and default routing closes #726 --- config.default.ini | 3 +- src/Application.php | 25 ++- src/Dispatch/Dispatcher.php | 4 + test/phpunit/ApplicationTest.php | 205 ++++++++++++++++++++++- test/phpunit/Dispatch/DispatcherTest.php | 12 ++ 5 files changed, 243 insertions(+), 6 deletions(-) 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..334dd084 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -1,8 +1,6 @@ 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 testStart_fallsBackToBasicErrorResponseWhenErrorPageRenderingFails():void { $this->resetApplicationLoggerState(); LogConfig::addHandler(new TestLogHandler()); @@ -671,6 +705,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 +945,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 = []; 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);

From 6f7eccba49db8734e0c7ea50102d7dfc99f4878f Mon Sep 17 00:00:00 2001
From: Greg Bowler 
Date: Sun, 19 Jul 2026 10:55:03 +0100
Subject: [PATCH 2/2] test: improve coverage

---
 test/phpunit/ApplicationTest.php   | 326 ++++++++++++++++++++++++++++-
 test/phpunit/DefaultRouterTest.php | 101 +++++++++
 2 files changed, 425 insertions(+), 2 deletions(-)

diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php
index 334dd084..c0ed1124 100644
--- a/test/phpunit/ApplicationTest.php
+++ b/test/phpunit/ApplicationTest.php
@@ -3,6 +3,7 @@
 
 use Closure;
 use Exception;
+use GT\Csrf\HTMLDocumentProtector;
 use GT\Config\Config;
 use GT\Config\ConfigFactory;
 use GT\Http\RequestFactory;
@@ -34,6 +35,14 @@ protected function tearDown():void {
 		parent::tearDown();
 	}
 
+	public function testDefaultConfig_usesHtmlRoutingAndDoesNotLogNotModifiedResponses():void {
+		$configFile = dirname(__DIR__, 2) . "/config.default.ini";
+		$config = parse_ini_file($configFile, true, INI_SCANNER_RAW);
+
+		self::assertSame("text/html", $config["router"]["default_content_type"]);
+		self::assertSame("false", $config["logger"]["log_not_modified"]);
+	}
+
 	public function testStart_callsRedirectExecute():void {
 		$redirect = self::createMock(Redirect::class);
 		$redirect->expects(self::once())
@@ -432,12 +441,175 @@ public function testStart_returnsNotModifiedWithoutErrorDispatcher():void {
 		self::assertCount(0, TestLogHandler::$records);
 	}
 
-	public function testStart_fallsBackToBasicErrorResponseWhenErrorPageRenderingFails():void {
+	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"));
@@ -478,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"]);
@@ -567,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);
 	}
@@ -659,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 = [];
@@ -1037,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;