From 6ec695d7104d9f4668f079a83b7f20185b7ec075 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 1 Jan 2025 10:01:26 -0500 Subject: [PATCH] refactor(head-analytics): migrate php code to an action, add coverage (#2974) --- app/Actions/ProcessPlausibleUrlAction.php | 210 ++++++++++++++ .../views/components/head-analytics.blade.php | 54 +--- .../Actions/ProcessPlausibleUrlActionTest.php | 272 ++++++++++++++++++ 3 files changed, 492 insertions(+), 44 deletions(-) create mode 100644 app/Actions/ProcessPlausibleUrlAction.php create mode 100644 tests/Feature/Actions/ProcessPlausibleUrlActionTest.php diff --git a/app/Actions/ProcessPlausibleUrlAction.php b/app/Actions/ProcessPlausibleUrlAction.php new file mode 100644 index 0000000000..7a25e6fcca --- /dev/null +++ b/app/Actions/ProcessPlausibleUrlAction.php @@ -0,0 +1,210 @@ +addModelRoute('game', Game::class, 'title'); + $this->addModelRoute('achievement', Achievement::class, 'title'); + $this->addModelRoute('hub', GameSet::class, 'title'); + $this->addModelRoute('system', System::class, 'name'); + + // Routes that just use a string parameter. + $this->addStringRoute('user', 'username'); + + // Routes that just use an ID. + $this->addIdRoute('ticket'); + + // Legacy routes that need special handling. + $this->addLegacyRoute('leaderboardinfo.php', ['i' => 'id']); + $this->addLegacyRoute('viewtopic.php', ['t' => 'topicId']); + $this->addLegacyRoute('viewforum.php', ['f' => 'forumId']); + $this->addLegacyRoute('forum.php', ['c' => 'categoryId']); + + // Routes with nested ID-slug segments. + // TODO $this->addNestedRoute('forums', ['category', 'forum']); + } + + public function execute(string $url, array $queryParams = [], array $defaultProps = []): array + { + // Split the URL into path components. + $path = trim($url, '/'); + $segments = explode('/', $path); + if (count($segments) < 1) { + return [ + 'redactedUrl' => "/{$path}", + 'props' => $defaultProps, + ]; + } + + $routePath = $segments[0]; + $param = $segments[1] ?? null; + $suffix = count($segments) > 2 ? '/' . implode('/', array_slice($segments, 2)) : ''; + + if (!isset($this->routes[$routePath])) { + // Handle unknown paths that might have numeric IDs. + if ($param && is_numeric($param)) { + return [ + 'redactedUrl' => "/{$routePath}/_PARAM_{$suffix}", + 'props' => ['id' => (int) $param] + $defaultProps, + ]; + } + + return [ + 'redactedUrl' => "/{$path}", + 'props' => $defaultProps, + ]; + } + + $route = $this->routes[$routePath]; + $props = []; + + switch ($route['type']) { + case 'model': + $id = $this->extractId($param); + if ($id && $model = $route['model']::find($id)) { + $props = [ + 'id' => $id, + strtolower($route['titleField']) => $model->{$route['titleField']}, + ]; + } elseif ($id) { + $props = ['id' => $id]; + } + break; + + case 'string': + $props = [$route['propName'] => $param]; + break; + + case 'id': + if ($param && is_numeric($param)) { + $props = ['id' => (int) $param]; + } + break; + + case 'legacy': + foreach ($route['queryMap'] as $queryParam => $propName) { + if (isset($queryParams[$queryParam])) { + $props[$propName] = (int) $queryParams[$queryParam]; + } + } + + return [ + 'redactedUrl' => "/{$routePath}", + 'props' => $props + $defaultProps, + ]; + + case 'nested': + // TODO return $this->handleNestedRoute($route, $segments); + } + + return [ + 'redactedUrl' => "/{$routePath}/_PARAM_{$suffix}", + 'props' => $props + $defaultProps, + ]; + } + + /** + * Adds a route that supports both direct ID and slug-with-ID access. + */ + private function addModelRoute(string $path, string $model, string $titleField): void + { + $this->routes[$path] = [ + 'type' => 'model', + 'model' => $model, + 'titleField' => $titleField, + ]; + } + + /** + * Adds a route that uses a string parameter. + */ + private function addStringRoute(string $path, string $propName): void + { + $this->routes[$path] = [ + 'type' => 'string', + 'propName' => $propName, + ]; + } + + /** + * Adds a route that just uses an ID parameter. + */ + private function addIdRoute(string $path): void + { + $this->routes[$path] = [ + 'type' => 'id', + ]; + } + + /** + * Adds a legacy route that needs special query parameter handling. + */ + private function addLegacyRoute(string $path, array $queryMap): void + { + $this->routes[$path] = [ + 'type' => 'legacy', + 'queryMap' => $queryMap, + ]; + } + + /** + * Adds a route with nested ID-slug segments. + */ + // private function addNestedRoute(string $path, array $segments): void + // { + // $this->routes[$path] = [ + // 'type' => 'nested', + // 'segments' => $segments, + // ]; + // } + + /** Processes a nested route with ID-slug segments. */ + // private function handleNestedRoute(array $route, array $urlSegments): array + // { + // // TODO + // return []; + // } + + /** + * Extracts an ID from either a direct ID or a slug-with-ID route. + */ + private function extractId(string $param): ?int + { + // Check for slug format first (eg: "sonic-3-123"). + if (preg_match('/-(\d+)$/', $param, $matches)) { + return (int) $matches[1]; + } + + // Fall back to direct ID if it's numeric. + return is_numeric($param) ? (int) $param : null; + } +} diff --git a/resources/views/components/head-analytics.blade.php b/resources/views/components/head-analytics.blade.php index b96291c9b7..c9e0723c9d 100644 --- a/resources/views/components/head-analytics.blade.php +++ b/resources/views/components/head-analytics.blade.php @@ -10,57 +10,23 @@ is passed along to Plausible as a custom prop. --}} -@php - // Get the current URL path and query parameters. - $url = request()->path(); - $queryParams = request()->query(); - - // Check if the URL should be redacted - if (preg_match('/\/\d+$/', $url) || preg_match('/\/\d+\//', $url) || preg_match('/^user\/[^\/]+/', $url)) { - // Redact dynamic segments in the URL. - $redactedUrl = preg_replace('/\d+/', '_PARAM_', $url); - - // Additionally redact the user routes. - $redactedUrl = preg_replace('/^user\/[^\/]+/', 'user/_PARAM_', $redactedUrl); - } else { - $redactedUrl = "/$url"; - } - - if ($redactedUrl === '//') { - $redactedUrl = '/'; - } - if (!str_starts_with($redactedUrl, '/')) { - $redactedUrl = "/{$redactedUrl}"; - } +@use('App\Actions\ProcessPlausibleUrlAction') - $props = [ +@php + $defaultProps = [ 'isAuthenticated' => auth()->check(), 'scheme' => request()->cookie('scheme') ?: 'dark', 'theme' => request()->cookie('theme') ?: 'default', ]; - // Define regex patterns to extract props from the URL. - $patterns = [ - '/^system\/(\d+)\//' => 'system', - '/^game\/(\d+)$/' => 'game', - '/^achievement\/(\d+)$/' => 'achievement', - '/^user\/([^\/]+)(\/progress)?$/' => 'user', - '/^game\/(\d+)\/hashes$/' => 'game', - '/^ticket\/(\d+)$/' => 'ticket', - ]; - - // Loop through each pattern to extract props. - foreach ($patterns as $regex => $prop) { - if (preg_match($regex, $url, $matches)) { - $props[$prop] = $matches[1]; - break; - } - } + $result = (new ProcessPlausibleUrlAction())->execute( + request()->path(), + request()->query(), + $defaultProps, + ); - // Track what topic ID users are viewing at viewtopic.php. - if (strpos($url, 'viewtopic') !== false && isset($queryParams['t'])) { - $props['topicId'] = $queryParams['t']; - } + $redactedUrl = $result['redactedUrl']; + $props = $result['props']; @endphp @if (app()->environment('local')) diff --git a/tests/Feature/Actions/ProcessPlausibleUrlActionTest.php b/tests/Feature/Actions/ProcessPlausibleUrlActionTest.php new file mode 100644 index 0000000000..5f416a9f7e --- /dev/null +++ b/tests/Feature/Actions/ProcessPlausibleUrlActionTest.php @@ -0,0 +1,272 @@ +action = new ProcessPlausibleUrlAction(); + $this->defaultProps = [ + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ]; + } + + public function testItCorrectlyHandlesLegacyGameUrls(): void + { + // Arrange + Game::factory()->create(['ID' => 1, 'Title' => 'Sonic the Hedgehog']); + + // Act + $result = $this->action->execute('game/1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/game/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'title' => 'Sonic the Hedgehog', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesSelfHealingGameUrls(): void + { + // Arrange + Game::factory()->create(['ID' => 1, 'Title' => 'Sonic the Hedgehog 3']); + + // Act + $result = $this->action->execute('game/sonic-the-hedgehog-3-1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/game/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'title' => 'Sonic the Hedgehog 3', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesNestedGameUrls(): void + { + // Arrange + Game::factory()->create(['ID' => 1, 'Title' => 'Sonic the Hedgehog']); + + // Act + $result = $this->action->execute('game/1/foo', [], $this->defaultProps); + + // Assert + $this->assertEquals('/game/_PARAM_/foo', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'title' => 'Sonic the Hedgehog', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesLegacyAchievementUrls(): void + { + // Arrange + Achievement::factory()->create(['ID' => 15, 'Title' => "Don't Get Lost"]); + + // Act + $result = $this->action->execute('achievement/15', [], $this->defaultProps); + + // Assert + $this->assertEquals('/achievement/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 15, + 'title' => "Don't Get Lost", + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesSelfHealingAchievementUrls(): void + { + // Arrange + Achievement::factory()->create(['ID' => 15, 'Title' => "Don't Get Lost"]); + + // Act + $result = $this->action->execute('achievement/dont-get-lost-15', [], $this->defaultProps); + + // Assert + $this->assertEquals('/achievement/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 15, + 'title' => "Don't Get Lost", + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesUserUrls(): void + { + // Arrange + User::factory()->create(['ID' => 1, 'User' => 'Scott']); + + // Act + $result = $this->action->execute('user/Scott', [], $this->defaultProps); + + // Assert + $this->assertEquals('/user/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'username' => 'Scott', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesNestedUserUrls(): void + { + // Arrange + User::factory()->create(['ID' => 1, 'User' => 'Scott']); + + // Act + $result = $this->action->execute('user/Scott/progress', [], $this->defaultProps); + + // Assert + $this->assertEquals('/user/_PARAM_/progress', $result['redactedUrl']); + $this->assertEquals([ + 'username' => 'Scott', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesSystemUrls(): void + { + // Arrange + System::factory()->create(['ID' => 1, 'Name' => 'Game Boy']); + + // Act + $result = $this->action->execute('system/game-boy-1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/system/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'name' => 'Game Boy', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesHubUrls(): void + { + // Arrange + GameSet::factory()->create(['id' => 1, 'type' => GameSetType::Hub, 'title' => '[Series - Mega Man]']); + + // Act + $result = $this->action->execute('hub/1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/hub/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'title' => '[Series - Mega Man]', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesSelfHealingHubUrls(): void + { + // Arrange + GameSet::factory()->create(['id' => 2, 'type' => GameSetType::Hub, 'title' => '[Central - Genre & Subgenre]']); + + // Act + $result = $this->action->execute('hub/central-genre-subgenre-2', [], $this->defaultProps); + + // Assert + $this->assertEquals('/hub/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 2, + 'title' => '[Central - Genre & Subgenre]', + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesTicketUrls(): void + { + // Arrange + Ticket::factory()->create(['ID' => 1]); + + // Act + $result = $this->action->execute('ticket/1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/ticket/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesLegacyViewtopicUrls(): void + { + // Act + $result = $this->action->execute('viewtopic.php', ['t' => '123'], $this->defaultProps); + + // Assert + $this->assertEquals('/viewtopic.php', $result['redactedUrl']); + $this->assertEquals([ + 'topicId' => 123, + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } + + public function testItCorrectlyHandlesUnknownEntityUrls(): void + { + // Act + $result = $this->action->execute('thing/1', [], $this->defaultProps); + + // Assert + $this->assertEquals('/thing/_PARAM_', $result['redactedUrl']); + $this->assertEquals([ + 'id' => 1, + 'isAuthenticated' => true, + 'scheme' => 'dark', + 'theme' => 'default', + ], $result['props']); + } +}