From 0f8cd69e942aa0c67eff653e79491a80375f8fc7 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 10 Dec 2025 13:19:38 +0100 Subject: [PATCH] Update Steam support --- Blacklight/Steam.php | 99 +- app/Console/Commands/SteamLookupGame.php | 181 ++++ app/Console/Commands/SteamSearchGames.php | 68 ++ app/Services/SteamService.php | 1057 +++++++++++++++++++++ app/Support/DTOs/SteamGameData.php | 307 ++++++ app/Support/DTOs/SteamPriceData.php | 102 ++ tests/Feature/SteamServiceTest.php | 453 +++++++++ tests/Unit/SteamDTOsTest.php | 306 ++++++ tests/Unit/SteamServiceTest.php | 357 +++++++ 9 files changed, 2906 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/SteamLookupGame.php create mode 100644 app/Console/Commands/SteamSearchGames.php create mode 100644 app/Services/SteamService.php create mode 100644 app/Support/DTOs/SteamGameData.php create mode 100644 app/Support/DTOs/SteamPriceData.php create mode 100644 tests/Feature/SteamServiceTest.php create mode 100644 tests/Unit/SteamDTOsTest.php create mode 100644 tests/Unit/SteamServiceTest.php diff --git a/Blacklight/Steam.php b/Blacklight/Steam.php index c55ef1a1f..d07e2caf5 100755 --- a/Blacklight/Steam.php +++ b/Blacklight/Steam.php @@ -3,12 +3,20 @@ namespace Blacklight; use App\Models\SteamApp; +use App\Services\SteamService; +use App\Support\DTOs\SteamGameData; use b3rs3rk\steamfront\Main; use DivineOmega\CliProgressBar\ProgressBar; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Log; /** * Class Steam. + * + * Legacy wrapper around SteamService for backward compatibility. + * New code should use App\Services\SteamService directly. + * + * @deprecated Use App\Services\SteamService instead */ class Steam { @@ -30,10 +38,11 @@ class Steam protected ColorCLI $colorCli; + protected SteamService $steamService; + /** * Steam constructor. * - * * @throws \Exception */ public function __construct() @@ -46,6 +55,7 @@ class Steam ); $this->colorCli = new ColorCLI; + $this->steamService = new SteamService(); } /** @@ -55,6 +65,13 @@ class Steam */ public function getAll(int $appID): bool|array { + // Try new service first + $result = $this->steamService->getGameDetails($appID); + if ($result !== false) { + return $result; + } + + // Fall back to legacy steamfront library $res = $this->steamFront->getAppDetails($appID); if ($res !== false) { @@ -152,24 +169,39 @@ class Steam } /** - * Searches Steam Apps table for best title match -- prefers 100% match but returns highest over 90%. + * Searches Steam Apps table for best title match. * * @param string $searchTerm The parsed game name from the release searchname - * @return false|int $bestMatch The Best match from the given search term + * @return false|int The Best match from the given search term * * @throws \Exception */ public function search(string $searchTerm): bool|int { - $bestMatch = false; - $searchTerm = trim($searchTerm); if ($searchTerm === '') { $this->colorCli->notice('Search term cannot be empty'); - return false; } + // Use new service for search + $appId = $this->steamService->search($searchTerm); + + if ($appId !== null) { + return $appId; + } + + // Fall back to legacy matching if new service fails + return $this->legacySearch($searchTerm); + } + + /** + * Legacy search implementation for backward compatibility. + */ + protected function legacySearch(string $searchTerm): bool|int + { + $bestMatch = false; + // Generate query variants from the original term to improve recall. $variants = $this->generateQueryVariants($searchTerm); @@ -265,32 +297,51 @@ class Steam public function populateSteamAppsTable(): void { $bar = new ProgressBar; - $fullAppArray = $this->steamFront->getFullAppList(); - $inserted = $dupe = 0; $this->colorCli->info('Populating steam apps table'); - $appsArray = Arr::pluck($fullAppArray, 'apps'); - $max = count($appsArray[0]); - $bar->setMaxProgress($max); - foreach ($appsArray as $appArray) { - foreach ($appArray as $app) { - $dupeCheck = SteamApp::query()->where('appid', '=', $app['appid'])->first(['appid']); - if ($dupeCheck === null) { - SteamApp::query()->insert(['name' => $app['name'], 'appid' => $app['appid']]); - $inserted++; - } else { - $dupe++; - } - $bar->advance()->display(); + + $stats = $this->steamService->populateSteamAppsTable(function ($processed, $total) use ($bar) { + if ($bar->getMaxProgress() === 0) { + $bar->setMaxProgress($total); } - } + $bar->setProgress($processed)->display(); + }); $bar->complete(); - \Laravel\Prompts\info('Added '.$inserted.' new steam app(s), '.$dupe.' duplicates skipped'); + \Laravel\Prompts\info(sprintf( + 'Added %d new steam app(s), %d skipped, %d errors', + $stats['inserted'], + $stats['skipped'], + $stats['errors'] + )); + } + + /** + * Get player count for a game. + */ + public function getPlayerCount(int $appId): ?int + { + return $this->steamService->getPlayerCount($appId); + } + + /** + * Get reviews summary for a game. + */ + public function getReviewsSummary(int $appId): ?array + { + return $this->steamService->getReviewsSummary($appId); + } + + /** + * Get the underlying SteamService instance. + */ + public function getService(): SteamService + { + return $this->steamService; } // -------------------- - // Matching helpers + // Matching helpers (kept for backward compatibility) // -------------------- /** diff --git a/app/Console/Commands/SteamLookupGame.php b/app/Console/Commands/SteamLookupGame.php new file mode 100644 index 000000000..d0264aad6 --- /dev/null +++ b/app/Console/Commands/SteamLookupGame.php @@ -0,0 +1,181 @@ +argument('title'); + $showDetails = $this->option('details'); + $outputJson = $this->option('json'); + + $this->info("Searching for: {$title}"); + $this->newLine(); + + // Search for the game + $appId = $steamService->search($title); + + if ($appId === null) { + $this->error('No matching game found.'); + return Command::FAILURE; + } + + $this->info("Found game with Steam App ID: {$appId}"); + $this->newLine(); + + if ($showDetails) { + $details = $steamService->getGameDetails($appId); + + if ($details === false) { + $this->error('Could not retrieve game details.'); + return Command::FAILURE; + } + + if ($outputJson) { + $this->line(json_encode($details, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->displayGameDetails($details); + } + + // Get additional data + $reviews = $steamService->getReviewsSummary($appId); + $playerCount = $steamService->getPlayerCount($appId); + + if ($reviews !== null) { + $this->newLine(); + $this->info('Reviews:'); + $this->table( + ['Metric', 'Value'], + [ + ['Rating', $reviews['review_score_desc'] ?? 'Unknown'], + ['Positive', number_format($reviews['total_positive'] ?? 0)], + ['Negative', number_format($reviews['total_negative'] ?? 0)], + ['Total', number_format($reviews['total_reviews'] ?? 0)], + ] + ); + } + + if ($playerCount !== null) { + $this->info("Current Players: " . number_format($playerCount)); + } + } + + return Command::SUCCESS; + } + + /** + * Display game details in a formatted way. + */ + protected function displayGameDetails(array $details): void + { + $this->info('=== Game Details ==='); + $this->newLine(); + + // Basic info table + $basicInfo = [ + ['Title', $details['title'] ?? 'N/A'], + ['Steam ID', $details['steamid'] ?? 'N/A'], + ['Type', ucfirst($details['type'] ?? 'game')], + ['Publisher', $details['publisher'] ?? 'N/A'], + ['Developers', implode(', ', $details['developers'] ?? []) ?: 'N/A'], + ['Release Date', $details['releasedate'] ?? 'N/A'], + ['Genres', $details['genres'] ?? 'N/A'], + ['Platforms', implode(', ', $details['platforms'] ?? [])], + ]; + + $this->table(['Property', 'Value'], $basicInfo); + + // Scores + if (!empty($details['metacritic_score'])) { + $this->newLine(); + $this->info("Metacritic Score: {$details['metacritic_score']}"); + } + + // Price + if (!empty($details['price'])) { + $price = $details['price']; + $this->newLine(); + $this->info('Pricing:'); + + $priceInfo = [ + ['Currency', $price['currency'] ?? 'USD'], + ['Price', $price['final_formatted'] ?? sprintf('$%.2f', $price['final'] ?? 0)], + ]; + + if (($price['discount_percent'] ?? 0) > 0) { + $priceInfo[] = ['Original', sprintf('$%.2f', $price['initial'] ?? 0)]; + $priceInfo[] = ['Discount', "{$price['discount_percent']}%"]; + } + + $this->table(['Field', 'Value'], $priceInfo); + } + + // Description + if (!empty($details['description'])) { + $this->newLine(); + $this->info('Description:'); + $this->line(wordwrap($details['description'], 80)); + } + + // URLs + $this->newLine(); + $this->info('Links:'); + $this->line("Store: {$details['directurl']}"); + + if (!empty($details['website'])) { + $this->line("Website: {$details['website']}"); + } + + if (!empty($details['metacritic_url'])) { + $this->line("Metacritic: {$details['metacritic_url']}"); + } + + // Stats + if (!empty($details['achievements']) || !empty($details['recommendations'])) { + $this->newLine(); + $this->info('Stats:'); + + if (!empty($details['achievements'])) { + $this->line("Achievements: {$details['achievements']}"); + } + + if (!empty($details['recommendations'])) { + $this->line("Recommendations: " . number_format($details['recommendations'])); + } + } + + // Categories (multiplayer, etc.) + if (!empty($details['categories'])) { + $this->newLine(); + $this->info('Features:'); + $this->line(implode(', ', $details['categories'])); + } + } +} + diff --git a/app/Console/Commands/SteamSearchGames.php b/app/Console/Commands/SteamSearchGames.php new file mode 100644 index 000000000..ac629bf27 --- /dev/null +++ b/app/Console/Commands/SteamSearchGames.php @@ -0,0 +1,68 @@ +argument('query'); + $limit = (int) $this->option('limit'); + + info("Searching for: {$query}"); + + $results = $steamService->searchMultiple($query, $limit); + + if ($results->isEmpty()) { + warning('No games found matching your query.'); + return Command::SUCCESS; + } + + info("Found {$results->count()} result(s):"); + + $tableData = $results->map(fn($r) => [ + $r['appid'], + substr($r['name'], 0, 60), + number_format($r['score'], 1) . '%', + 'https://store.steampowered.com/app/' . $r['appid'], + ])->toArray(); + + table( + ['App ID', 'Name', 'Score', 'Store URL'], + $tableData + ); + + return Command::SUCCESS; + } +} + diff --git a/app/Services/SteamService.php b/app/Services/SteamService.php new file mode 100644 index 000000000..ffe9f13e8 --- /dev/null +++ b/app/Services/SteamService.php @@ -0,0 +1,1057 @@ +apiKey = $apiKey ?? config('services.steam.api_key'); + } + + /** + * Search for a game by title and return the best match's Steam App ID. + */ + public function search(string $title): ?int + { + $cleanTitle = $this->cleanTitle($title); + if (empty($cleanTitle)) { + Log::debug('SteamService: Empty title after cleaning', ['original' => $title]); + return null; + } + + // Check failed lookup cache + $cacheKey = 'steam_search_failed:' . md5(mb_strtolower($cleanTitle)); + if (Cache::has($cacheKey)) { + Log::debug('SteamService: Skipping previously failed search', ['title' => $cleanTitle]); + return null; + } + + // Check successful search cache + $successCacheKey = 'steam_search:' . md5(mb_strtolower($cleanTitle)); + $cached = Cache::get($successCacheKey); + if ($cached !== null) { + Log::debug('SteamService: Using cached search result', ['title' => $cleanTitle, 'appid' => $cached]); + return (int) $cached; + } + + $appId = $this->performSearch($cleanTitle); + + if ($appId !== null) { + Cache::put($successCacheKey, $appId, self::SEARCH_CACHE_TTL); + Log::info('SteamService: Found match', ['title' => $cleanTitle, 'appid' => $appId]); + } else { + Cache::put($cacheKey, true, self::FAILED_LOOKUP_CACHE_TTL); + Log::debug('SteamService: No match found', ['title' => $cleanTitle]); + } + + return $appId; + } + + /** + * Get complete game details from Steam. + * + * @return array{ + * title: string, + * steamid: int, + * description: ?string, + * detailed_description: ?string, + * about: ?string, + * short_description: ?string, + * cover: ?string, + * backdrop: ?string, + * screenshots: array, + * movies: array, + * trailer: ?string, + * publisher: ?string, + * developers: array, + * releasedate: ?string, + * genres: string, + * categories: array, + * rating: ?int, + * metacritic_score: ?int, + * metacritic_url: ?string, + * price: ?array, + * platforms: array, + * requirements: array, + * dlc: array, + * achievements: ?int, + * recommendations: ?int, + * website: ?string, + * support_url: ?string, + * legal_notice: ?string, + * directurl: string, + * type: string, + * }|false + */ + public function getGameDetails(int $appId): array|false + { + // Check cache first + $cacheKey = "steam_app_details:{$appId}"; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $data = $this->fetchAppDetails($appId); + if ($data === null) { + return false; + } + + $result = $this->transformAppDetails($data, $appId); + + Cache::put($cacheKey, $result, self::APP_DETAILS_CACHE_TTL); + + return $result; + } + + /** + * Alias for getGameDetails for backward compatibility. + */ + public function getAll(int $appId): array|false + { + return $this->getGameDetails($appId); + } + + /** + * Get Steam player count for a game. + */ + public function getPlayerCount(int $appId): ?int + { + if (empty($this->apiKey)) { + return null; + } + + $cacheKey = "steam_player_count:{$appId}"; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return (int) $cached; + } + + try { + $response = $this->makeRequest( + self::STEAM_API_BASE . '/ISteamUserStats/GetNumberOfCurrentPlayers/v1/', + ['appid' => $appId] + ); + + if ($response && isset($response['response']['player_count'])) { + $count = (int) $response['response']['player_count']; + Cache::put($cacheKey, $count, 300); // 5 minute cache + return $count; + } + } catch (\Exception $e) { + Log::warning('SteamService: Failed to get player count', [ + 'appid' => $appId, + 'error' => $e->getMessage() + ]); + } + + return null; + } + + /** + * Get game reviews summary. + */ + public function getReviewsSummary(int $appId): ?array + { + $cacheKey = "steam_reviews:{$appId}"; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $response = Http::timeout(10) + ->get(self::STEAM_STORE_BASE . '/appreviews/' . $appId, [ + 'json' => 1, + 'language' => 'all', + 'purchase_type' => 'all', + ]) + ->json(); + + if ($response && isset($response['query_summary'])) { + $summary = [ + 'total_positive' => $response['query_summary']['total_positive'] ?? 0, + 'total_negative' => $response['query_summary']['total_negative'] ?? 0, + 'total_reviews' => $response['query_summary']['total_reviews'] ?? 0, + 'review_score' => $response['query_summary']['review_score'] ?? 0, + 'review_score_desc' => $response['query_summary']['review_score_desc'] ?? 'No Reviews', + ]; + + Cache::put($cacheKey, $summary, 3600); // 1 hour cache + return $summary; + } + } catch (\Exception $e) { + Log::warning('SteamService: Failed to get reviews', [ + 'appid' => $appId, + 'error' => $e->getMessage() + ]); + } + + return null; + } + + /** + * Get DLC list for a game. + */ + public function getDLCList(int $appId): array + { + $details = $this->getGameDetails($appId); + if ($details === false) { + return []; + } + + $dlcIds = $details['dlc'] ?? []; + if (empty($dlcIds)) { + return []; + } + + $dlcList = []; + foreach (array_slice($dlcIds, 0, 20) as $dlcId) { // Limit to prevent too many API calls + $dlcDetails = $this->getGameDetails($dlcId); + if ($dlcDetails !== false) { + $dlcList[] = [ + 'appid' => $dlcId, + 'name' => $dlcDetails['title'], + 'price' => $dlcDetails['price'] ?? null, + ]; + } + } + + return $dlcList; + } + + /** + * Populate the steam_apps table with the full app list from Steam. + */ + public function populateSteamAppsTable(?callable $progressCallback = null): array + { + $stats = ['inserted' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0]; + + try { + $appList = $this->getFullAppList(); + if (empty($appList)) { + Log::error('SteamService: Failed to retrieve app list from Steam'); + return $stats; + } + + $total = count($appList); + $processed = 0; + + // Process in chunks to avoid memory issues + $chunks = array_chunk($appList, 1000); + + foreach ($chunks as $chunk) { + $existingApps = SteamApp::query() + ->whereIn('appid', Arr::pluck($chunk, 'appid')) + ->pluck('appid') + ->toArray(); + + $toInsert = []; + foreach ($chunk as $app) { + $processed++; + + if (empty($app['name']) || !isset($app['appid'])) { + $stats['skipped']++; + continue; + } + + if (in_array($app['appid'], $existingApps, true)) { + $stats['skipped']++; + continue; + } + + $toInsert[] = [ + 'appid' => $app['appid'], + 'name' => $app['name'], + ]; + } + + if (!empty($toInsert)) { + try { + SteamApp::query()->insert($toInsert); + $stats['inserted'] += count($toInsert); + } catch (\Exception $e) { + Log::warning('SteamService: Batch insert failed, trying individual inserts', [ + 'error' => $e->getMessage() + ]); + + foreach ($toInsert as $app) { + try { + SteamApp::query()->insertOrIgnore($app); + $stats['inserted']++; + } catch (\Exception $e2) { + $stats['errors']++; + } + } + } + } + + if ($progressCallback !== null) { + $progressCallback($processed, $total); + } + } + + Log::info('SteamService: App list populated', $stats); + } catch (\Exception $e) { + Log::error('SteamService: Failed to populate app list', ['error' => $e->getMessage()]); + $stats['errors']++; + } + + return $stats; + } + + /** + * Get the full list of Steam apps. + */ + public function getFullAppList(): array + { + $cacheKey = 'steam_full_app_list'; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $response = Http::timeout(60) + ->get(self::STEAM_API_BASE . '/ISteamApps/GetAppList/v2/') + ->json(); + + if ($response && isset($response['applist']['apps'])) { + $apps = $response['applist']['apps']; + Cache::put($cacheKey, $apps, self::APP_LIST_CACHE_TTL); + return $apps; + } + } catch (\Exception $e) { + Log::error('SteamService: Failed to fetch app list', ['error' => $e->getMessage()]); + } + + return []; + } + + /** + * Search for games and return multiple matches. + * + * @return Collection + */ + public function searchMultiple(string $title, int $limit = 10): Collection + { + $cleanTitle = $this->cleanTitle($title); + if (empty($cleanTitle)) { + return collect(); + } + + $matches = $this->findMatches($cleanTitle, $limit * 2); // Get more to filter + + return collect($matches) + ->filter(fn($m) => $m['score'] >= self::RELAXED_MATCH_THRESHOLD) + ->sortByDesc('score') + ->take($limit) + ->values(); + } + + // ======================================== + // Protected Methods + // ======================================== + + /** + * Perform the actual search with multiple strategies. + */ + protected function performSearch(string $title): ?int + { + $matches = $this->findMatches($title); + + if (empty($matches)) { + return null; + } + + // Get the best match + $bestMatch = $matches[0]; + + if ($bestMatch['score'] >= self::MATCH_THRESHOLD) { + return $bestMatch['appid']; + } + + // Try relaxed threshold for close matches + if ($bestMatch['score'] >= self::RELAXED_MATCH_THRESHOLD) { + // Verify with additional checks + $details = $this->fetchAppDetails($bestMatch['appid']); + if ($details !== null && $this->isGameType($details)) { + return $bestMatch['appid']; + } + } + + return null; + } + + /** + * Find matching games from the database. + * + * @return array + */ + protected function findMatches(string $title, int $limit = 25): array + { + $variants = $this->generateQueryVariants($title); + $matches = []; + $seenAppIds = []; + + foreach ($variants as $variant) { + // Try Scout full-text search first + try { + $results = SteamApp::search($variant)->take($limit)->get(); + foreach ($results as $result) { + $appid = $result->appid ?? null; + $name = $result->name ?? null; + + if ($appid === null || $name === null || isset($seenAppIds[$appid])) { + continue; + } + + $score = $this->scoreTitle($name, $title); + if ($score >= self::RELAXED_MATCH_THRESHOLD) { + $seenAppIds[$appid] = true; + $matches[] = [ + 'appid' => (int) $appid, + 'name' => $name, + 'score' => $score, + ]; + } + } + } catch (\Exception $e) { + Log::debug('SteamService: Scout search failed', ['error' => $e->getMessage()]); + } + + // LIKE fallback + $likeTerm = $this->buildLikePattern($variant); + try { + $fallbacks = SteamApp::query() + ->select(['appid', 'name']) + ->where('name', 'like', $likeTerm) + ->limit($limit) + ->get(); + + foreach ($fallbacks as $row) { + $appid = $row->appid; + $name = $row->name; + + if (isset($seenAppIds[$appid])) { + continue; + } + + $score = $this->scoreTitle($name, $title); + if ($score >= self::RELAXED_MATCH_THRESHOLD) { + $seenAppIds[$appid] = true; + $matches[] = [ + 'appid' => (int) $appid, + 'name' => $name, + 'score' => $score, + ]; + } + } + } catch (\Exception $e) { + Log::debug('SteamService: LIKE search failed', ['error' => $e->getMessage()]); + } + } + + // Sort by score descending + usort($matches, fn($a, $b) => $b['score'] <=> $a['score']); + + return array_slice($matches, 0, $limit); + } + + /** + * Fetch app details from Steam API. + */ + protected function fetchAppDetails(int $appId): ?array + { + return RateLimiter::attempt( + self::RATE_LIMIT_KEY, + self::REQUESTS_PER_MINUTE, + function () use ($appId) { + try { + $response = Http::timeout(15) + ->get(self::STEAM_STORE_BASE . '/appdetails', [ + 'appids' => $appId, + 'cc' => 'us', + 'l' => 'english', + ]) + ->json(); + + if ($response && isset($response[(string) $appId]['success']) && $response[(string) $appId]['success'] === true) { + return $response[(string) $appId]['data']; + } + } catch (\Exception $e) { + Log::warning('SteamService: Failed to fetch app details', [ + 'appid' => $appId, + 'error' => $e->getMessage() + ]); + } + + return null; + }, + self::DECAY_SECONDS + ); + } + + /** + * Transform Steam API response to our standard format. + */ + protected function transformAppDetails(array $data, int $appId): array + { + // Extract screenshots + $screenshots = []; + if (!empty($data['screenshots'])) { + foreach ($data['screenshots'] as $ss) { + $screenshots[] = [ + 'thumbnail' => $ss['path_thumbnail'] ?? null, + 'full' => $ss['path_full'] ?? null, + ]; + } + } + + // Extract movies/trailers + $movies = []; + $trailerUrl = null; + if (!empty($data['movies'])) { + foreach ($data['movies'] as $movie) { + $movieData = [ + 'id' => $movie['id'] ?? null, + 'name' => $movie['name'] ?? null, + 'thumbnail' => $movie['thumbnail'] ?? null, + 'webm' => $movie['webm']['max'] ?? ($movie['webm']['480'] ?? null), + 'mp4' => $movie['mp4']['max'] ?? ($movie['mp4']['480'] ?? null), + ]; + $movies[] = $movieData; + + if ($trailerUrl === null && !empty($movieData['mp4'])) { + $trailerUrl = $movieData['mp4']; + } + } + } + + // Extract genres + $genres = []; + if (!empty($data['genres'])) { + foreach ($data['genres'] as $genre) { + $genres[] = $genre['description'] ?? ''; + } + } + + // Extract categories (multiplayer, co-op, etc.) + $categories = []; + if (!empty($data['categories'])) { + foreach ($data['categories'] as $cat) { + $categories[] = $cat['description'] ?? ''; + } + } + + // Extract price info + $price = null; + if (isset($data['price_overview'])) { + $price = [ + 'currency' => $data['price_overview']['currency'] ?? 'USD', + 'initial' => ($data['price_overview']['initial'] ?? 0) / 100, + 'final' => ($data['price_overview']['final'] ?? 0) / 100, + 'discount_percent' => $data['price_overview']['discount_percent'] ?? 0, + 'final_formatted' => $data['price_overview']['final_formatted'] ?? null, + ]; + } elseif ($data['is_free'] ?? false) { + $price = [ + 'currency' => 'USD', + 'initial' => 0, + 'final' => 0, + 'discount_percent' => 0, + 'final_formatted' => 'Free', + ]; + } + + // Extract platforms + $platforms = []; + if (!empty($data['platforms'])) { + if ($data['platforms']['windows'] ?? false) { + $platforms[] = 'Windows'; + } + if ($data['platforms']['mac'] ?? false) { + $platforms[] = 'Mac'; + } + if ($data['platforms']['linux'] ?? false) { + $platforms[] = 'Linux'; + } + } + + // Extract requirements + $requirements = []; + if (!empty($data['pc_requirements'])) { + $requirements['pc'] = [ + 'minimum' => $data['pc_requirements']['minimum'] ?? null, + 'recommended' => $data['pc_requirements']['recommended'] ?? null, + ]; + } + if (!empty($data['mac_requirements'])) { + $requirements['mac'] = [ + 'minimum' => $data['mac_requirements']['minimum'] ?? null, + 'recommended' => $data['mac_requirements']['recommended'] ?? null, + ]; + } + if (!empty($data['linux_requirements'])) { + $requirements['linux'] = [ + 'minimum' => $data['linux_requirements']['minimum'] ?? null, + 'recommended' => $data['linux_requirements']['recommended'] ?? null, + ]; + } + + // Extract release date + $releaseDate = null; + if (!empty($data['release_date']['date'])) { + try { + $releaseDate = Carbon::parse($data['release_date']['date'])->format('Y-m-d'); + } catch (\Exception $e) { + $releaseDate = $data['release_date']['date']; + } + } + + // Build publisher string + $publisher = null; + if (!empty($data['publishers'])) { + $publisher = implode(', ', array_filter(array_map('strval', $data['publishers']))); + } + + // Build developers array + $developers = []; + if (!empty($data['developers'])) { + $developers = array_filter(array_map('strval', $data['developers'])); + } + + return [ + 'title' => $data['name'] ?? '', + 'steamid' => $appId, + 'type' => $data['type'] ?? 'game', + 'description' => $data['short_description'] ?? null, + 'detailed_description' => $data['detailed_description'] ?? null, + 'about' => $data['about_the_game'] ?? null, + 'short_description' => $data['short_description'] ?? null, + 'cover' => $data['header_image'] ?? null, + 'backdrop' => $data['background'] ?? ($data['background_raw'] ?? null), + 'screenshots' => $screenshots, + 'movies' => $movies, + 'trailer' => $trailerUrl, + 'publisher' => $publisher, + 'developers' => $developers, + 'releasedate' => $releaseDate, + 'genres' => implode(',', array_filter($genres)), + 'categories' => $categories, + 'rating' => $data['metacritic']['score'] ?? null, + 'metacritic_score' => $data['metacritic']['score'] ?? null, + 'metacritic_url' => $data['metacritic']['url'] ?? null, + 'price' => $price, + 'platforms' => $platforms, + 'requirements' => $requirements, + 'dlc' => $data['dlc'] ?? [], + 'achievements' => $data['achievements']['total'] ?? null, + 'recommendations' => $data['recommendations']['total'] ?? null, + 'website' => $data['website'] ?? null, + 'support_url' => $data['support_info']['url'] ?? null, + 'legal_notice' => $data['legal_notice'] ?? null, + 'directurl' => self::STEAM_STORE_URL . $appId, + ]; + } + + /** + * Check if the app is a game (not DLC, video, etc.). + */ + protected function isGameType(array $data): bool + { + $type = $data['type'] ?? ''; + return in_array($type, ['game', 'demo'], true); + } + + /** + * Make a rate-limited request to Steam API. + */ + protected function makeRequest(string $url, array $params = []): ?array + { + return RateLimiter::attempt( + self::RATE_LIMIT_KEY, + self::REQUESTS_PER_MINUTE, + function () use ($url, $params) { + if ($this->apiKey !== null) { + $params['key'] = $this->apiKey; + } + + try { + return Http::timeout(15)->get($url, $params)->json(); + } catch (\Exception $e) { + Log::warning('SteamService: API request failed', [ + 'url' => $url, + 'error' => $e->getMessage() + ]); + return null; + } + }, + self::DECAY_SECONDS + ); + } + + // ======================================== + // Title Matching & Normalization + // ======================================== + + /** + * Clean a release title for searching. + */ + public function cleanTitle(string $title): string + { + $title = trim($title); + if ($title === '') { + return ''; + } + + // URL decode + $title = urldecode($title); + + // Remove file extensions + $title = (string) preg_replace('/\.(zip|rar|7z|iso|nfo|sfv|exe|mkv|mp4|avi)$/i', '', $title); + + // Remove bracketed content + $title = (string) preg_replace('/\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/u', ' ', $title); + + // Remove scene groups at end + $groupPattern = '/\s*[-_]\s*(' . implode('|', array_map('preg_quote', self::SCENE_GROUPS)) . ')\s*$/i'; + $title = (string) preg_replace($groupPattern, '', $title); + + // Remove edition tags (multi-word patterns first) + $editionPatterns = [ + '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i', + '/\bGOTY[\s._-]*(Edition)?\b/i', + '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i', + '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i', + '/\bEdition\b/i', // Remove standalone "Edition" + ]; + foreach ($editionPatterns as $pattern) { + $title = (string) preg_replace($pattern, ' ', $title); + } + + // Remove standalone edition tags + foreach (self::EDITION_TAGS as $tag) { + // Skip tags already handled above + if (stripos($tag, 'EDITION') !== false) { + continue; + } + $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\b/i', ' ', $title); + } + + // Remove release tags + foreach (self::RELEASE_TAGS as $tag) { + $title = (string) preg_replace('/\b' . preg_quote($tag, '/') . '\d*\b/i', ' ', $title); + } + + // Remove DLCs tag (common in scene releases) + $title = (string) preg_replace('/\b(Incl(?:uding)?\.?\s*)?DLCs?\b/i', ' ', $title); + + // Remove version numbers (v1.2.3, 1.2.3.4, etc.) + $title = (string) preg_replace('/\bv?\d+(?:\.\d+){2,}\b/i', ' ', $title); + + // Replace separators with spaces + $title = (string) preg_replace('/[._+\-]+/', ' ', $title); + + // Clean up + $title = (string) preg_replace('/\s+/', ' ', $title); + $title = trim($title, " \t\n\r\0\x0B-_"); + + return $title; + } + + /** + * Generate query variants for better matching. + * + * @return array + */ + protected function generateQueryVariants(string $title): array + { + $variants = [$title]; + + // Without edition tags (already cleaned, but be sure) + $stripped = $this->stripEditionTags($title); + if ($stripped !== $title && $stripped !== '') { + $variants[] = $stripped; + } + + // Without parentheses content + $noParen = (string) preg_replace('/\s*\([^)]*\)\s*/', ' ', $stripped); + $noParen = trim(preg_replace('/\s+/', ' ', $noParen) ?? ''); + if ($noParen !== $stripped && $noParen !== '') { + $variants[] = $noParen; + } + + // Left side of colon (base title) + if (str_contains($noParen, ':')) { + $left = trim(explode(':', $noParen, 2)[0]); + if ($left !== '' && $left !== $noParen) { + $variants[] = $left; + } + } + + // Normalized version + $normalized = $this->normalizeTitle($noParen); + if ($normalized !== $noParen && $normalized !== '') { + $variants[] = $normalized; + } + + // De-duplicate while preserving order + $unique = []; + $seen = []; + foreach ($variants as $v) { + $v = trim($v); + if ($v === '') { + continue; + } + $lower = mb_strtolower($v); + if (!isset($seen[$lower])) { + $seen[$lower] = true; + $unique[] = $v; + } + } + + return $unique; + } + + /** + * Normalize a title for comparison. + */ + protected function normalizeTitle(string $title): string + { + $s = mb_strtolower($title); + + // Replace separators + $s = (string) preg_replace('/[._\-+]+/u', ' ', $s); + + // Convert roman numerals + $s = $this->replaceRomanNumerals($s); + + // Remove common noise + $noise = array_merge( + array_map('strtolower', self::SCENE_GROUPS), + array_map('strtolower', self::EDITION_TAGS), + array_map('strtolower', self::RELEASE_TAGS), + ['pc', 'win', 'windows', 'x86', 'x64', 'x32'] + ); + $noisePattern = '/\b(' . implode('|', array_map(fn($w) => preg_quote($w, '/'), $noise)) . ')\b/u'; + $s = (string) preg_replace($noisePattern, ' ', $s); + + // Remove non-alphanumeric + $s = (string) preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $s); + $s = (string) preg_replace('/\s+/u', ' ', $s); + $s = trim($s); + + // Remove leading articles + $s = (string) preg_replace('/^(the|a|an)\s+/u', '', $s); + + return $s; + } + + /** + * Strip edition tags from a title. + */ + protected function stripEditionTags(string $title): string + { + // Remove compound edition phrases first + $compoundPatterns = [ + '/\b(Game\s+of\s+the\s+Year)[\s._-]*(Edition)?\b/i', + '/\bGOTY[\s._-]*(Edition)?\b/i', + '/\b(Definitive|Deluxe|Ultimate|Complete|Enhanced|Special|Collectors?|Gold|Premium|Legendary|Standard|Digital)[\s._-]*(Edition)?\b/i', + '/\b(Remastered|HD[\s._-]*Remaster|Directors?[\s._-]*Cut|Anniversary)[\s._-]*(Edition)?\b/i', + '/\bEdition\b/i', // Remove standalone "Edition" + ]; + foreach ($compoundPatterns as $pattern) { + $title = (string) preg_replace($pattern, ' ', $title); + } + + // Remove remaining individual edition tags + foreach (self::EDITION_TAGS as $tag) { + $title = (string) preg_replace('/\s*[-_]?\s*' . preg_quote($tag, '/') . '\s*/i', ' ', $title); + } + return trim(preg_replace('/\s+/', ' ', $title) ?? ''); + } + + /** + * Score a candidate title against the original search term. + */ + protected function scoreTitle(string $candidate, string $original): float + { + $normCand = $this->normalizeTitle($candidate); + $normOrig = $this->normalizeTitle($original); + + if ($normCand === '' || $normOrig === '') { + return 0.0; + } + + // Perfect match + if ($normCand === $normOrig) { + return 100.0; + } + + // Token-based scoring + $tokensCand = $this->tokenize($normCand); + $tokensOrig = $this->tokenize($normOrig); + + // Token containment check + $intersect = count(array_intersect($tokensCand, $tokensOrig)); + $union = count(array_unique(array_merge($tokensCand, $tokensOrig))); + $jaccard = $union > 0 ? ($intersect / $union) : 0.0; + + // Levenshtein similarity + $lev = levenshtein($normCand, $normOrig); + $maxLen = max(strlen($normCand), strlen($normOrig)); + $levSim = $maxLen > 0 ? (1.0 - ($lev / $maxLen)) : 0.0; + + // Prefix bonus + $prefixBoost = 0.0; + if (str_starts_with($normCand, $normOrig) || str_starts_with($normOrig, $normCand)) { + $prefixBoost = 0.15; + } + + // If all tokens of one are in the other + $candInOrig = empty(array_diff($tokensCand, $tokensOrig)); + $origInCand = empty(array_diff($tokensOrig, $tokensCand)); + if ($candInOrig || $origInCand) { + $shortCount = min(count($tokensCand), count($tokensOrig)); + $longCount = max(count($tokensCand), count($tokensOrig)); + $coverage = $longCount > 0 ? ($shortCount / $longCount) : 0.0; + if ($coverage >= 0.8) { + return 100.0; + } + } + + // Combined score + $score = ($jaccard * 0.6 + $levSim * 0.4 + $prefixBoost) * 100.0; + + return max(0.0, min(100.0, $score)); + } + + /** + * Tokenize a string. + */ + protected function tokenize(string $s): array + { + $parts = preg_split('/\s+/u', mb_strtolower($s)) ?: []; + $seen = []; + $out = []; + foreach ($parts as $p) { + $p = trim($p); + if ($p === '' || isset($seen[$p])) { + continue; + } + $seen[$p] = true; + $out[] = $p; + } + return $out; + } + + /** + * Replace roman numerals with arabic numbers. + */ + protected function replaceRomanNumerals(string $s): string + { + $map = [ + 'xx' => '20', 'xix' => '19', 'xviii' => '18', 'xvii' => '17', 'xvi' => '16', + 'xv' => '15', 'xiv' => '14', 'xiii' => '13', 'xii' => '12', 'xi' => '11', + 'x' => '10', 'ix' => '9', 'viii' => '8', 'vii' => '7', 'vi' => '6', + 'v' => '5', 'iv' => '4', 'iii' => '3', 'ii' => '2', + ]; + + // Don't replace standalone 'i' as it's too common in titles + foreach ($map as $roman => $arabic) { + $s = (string) preg_replace('/\b' . $roman . '\b/ui', $arabic, $s); + } + + return $s; + } + + /** + * Build a SQL LIKE pattern from a search term. + */ + protected function buildLikePattern(string $term): string + { + $normalized = $this->normalizeTitle($term); + $pattern = preg_replace('/\s+/', '%', $normalized); + $pattern = trim($pattern ?? ''); + + return $pattern === '' ? '%' : '%' . $pattern . '%'; + } + + /** + * Clear all cached data. + */ + public function clearCache(): void + { + // Note: This is a placeholder. In production, you'd use tagged caches + // or implement specific cache key management. + Log::info('SteamService: Cache clear requested'); + } +} + diff --git a/app/Support/DTOs/SteamGameData.php b/app/Support/DTOs/SteamGameData.php new file mode 100644 index 000000000..1e73735d3 --- /dev/null +++ b/app/Support/DTOs/SteamGameData.php @@ -0,0 +1,307 @@ + $screenshots Screenshot URLs + * @param array $movies Movie/trailer data + * @param string|null $trailerUrl Primary trailer URL + * @param string|null $publisher Publisher name(s) + * @param array $developers Developer names + * @param string|null $releaseDate Release date (Y-m-d format) + * @param array $genres Genre names + * @param array $categories Category names (multiplayer, etc.) + * @param int|null $metacriticScore Metacritic score (0-100) + * @param string|null $metacriticUrl Metacritic URL + * @param SteamPriceData|null $price Price information + * @param array $platforms Supported platforms + * @param array $requirements System requirements + * @param array $dlcIds DLC App IDs + * @param int|null $achievementCount Total achievements + * @param int|null $recommendationCount Total recommendations + * @param string|null $website Official website URL + * @param string|null $supportUrl Support URL + * @param string $storeUrl Steam store URL + */ + public function __construct( + public int $steamId, + public string $title, + public string $type = 'game', + public ?string $description = null, + public ?string $detailedDescription = null, + public ?string $about = null, + public ?string $coverUrl = null, + public ?string $backdropUrl = null, + public array $screenshots = [], + public array $movies = [], + public ?string $trailerUrl = null, + public ?string $publisher = null, + public array $developers = [], + public ?string $releaseDate = null, + public array $genres = [], + public array $categories = [], + public ?int $metacriticScore = null, + public ?string $metacriticUrl = null, + public ?SteamPriceData $price = null, + public array $platforms = [], + public array $requirements = [], + public array $dlcIds = [], + public ?int $achievementCount = null, + public ?int $recommendationCount = null, + public ?string $website = null, + public ?string $supportUrl = null, + public string $storeUrl = '', + ) {} + + /** + * Create from Steam API response array. + */ + public static function fromApiResponse(array $data, int $appId): self + { + // Parse screenshots + $screenshots = []; + if (!empty($data['screenshots'])) { + foreach ($data['screenshots'] as $ss) { + $screenshots[] = [ + 'thumbnail' => $ss['path_thumbnail'] ?? null, + 'full' => $ss['path_full'] ?? null, + ]; + } + } + + // Parse movies + $movies = []; + $trailerUrl = null; + if (!empty($data['movies'])) { + foreach ($data['movies'] as $movie) { + $mp4Url = $movie['mp4']['max'] ?? ($movie['mp4']['480'] ?? null); + $movies[] = [ + 'id' => $movie['id'] ?? null, + 'name' => $movie['name'] ?? null, + 'thumbnail' => $movie['thumbnail'] ?? null, + 'webm' => $movie['webm']['max'] ?? ($movie['webm']['480'] ?? null), + 'mp4' => $mp4Url, + ]; + if ($trailerUrl === null && !empty($mp4Url)) { + $trailerUrl = $mp4Url; + } + } + } + + // Parse genres + $genres = []; + if (!empty($data['genres'])) { + foreach ($data['genres'] as $genre) { + if (!empty($genre['description'])) { + $genres[] = $genre['description']; + } + } + } + + // Parse categories + $categories = []; + if (!empty($data['categories'])) { + foreach ($data['categories'] as $cat) { + if (!empty($cat['description'])) { + $categories[] = $cat['description']; + } + } + } + + // Parse platforms + $platforms = []; + if (!empty($data['platforms'])) { + if ($data['platforms']['windows'] ?? false) { + $platforms[] = 'Windows'; + } + if ($data['platforms']['mac'] ?? false) { + $platforms[] = 'Mac'; + } + if ($data['platforms']['linux'] ?? false) { + $platforms[] = 'Linux'; + } + } + + // Parse requirements + $requirements = []; + if (!empty($data['pc_requirements']) && !is_array($data['pc_requirements']) === false) { + if (is_array($data['pc_requirements'])) { + $requirements['pc'] = [ + 'minimum' => $data['pc_requirements']['minimum'] ?? null, + 'recommended' => $data['pc_requirements']['recommended'] ?? null, + ]; + } + } + if (!empty($data['mac_requirements']) && is_array($data['mac_requirements'])) { + $requirements['mac'] = [ + 'minimum' => $data['mac_requirements']['minimum'] ?? null, + 'recommended' => $data['mac_requirements']['recommended'] ?? null, + ]; + } + if (!empty($data['linux_requirements']) && is_array($data['linux_requirements'])) { + $requirements['linux'] = [ + 'minimum' => $data['linux_requirements']['minimum'] ?? null, + 'recommended' => $data['linux_requirements']['recommended'] ?? null, + ]; + } + + // Parse price + $price = null; + if (isset($data['price_overview'])) { + $price = SteamPriceData::fromApiResponse($data['price_overview']); + } elseif ($data['is_free'] ?? false) { + $price = SteamPriceData::free(); + } + + // Parse release date + $releaseDate = null; + if (!empty($data['release_date']['date'])) { + try { + $releaseDate = \Illuminate\Support\Carbon::parse($data['release_date']['date'])->format('Y-m-d'); + } catch (\Exception $e) { + $releaseDate = $data['release_date']['date']; + } + } + + // Parse publisher + $publisher = null; + if (!empty($data['publishers'])) { + $publisher = implode(', ', array_filter(array_map('strval', $data['publishers']))); + } + + // Parse developers + $developers = []; + if (!empty($data['developers'])) { + $developers = array_values(array_filter(array_map('strval', $data['developers']))); + } + + return new self( + steamId: $appId, + title: $data['name'] ?? '', + type: $data['type'] ?? 'game', + description: $data['short_description'] ?? null, + detailedDescription: $data['detailed_description'] ?? null, + about: $data['about_the_game'] ?? null, + coverUrl: $data['header_image'] ?? null, + backdropUrl: $data['background'] ?? ($data['background_raw'] ?? null), + screenshots: $screenshots, + movies: $movies, + trailerUrl: $trailerUrl, + publisher: $publisher, + developers: $developers, + releaseDate: $releaseDate, + genres: $genres, + categories: $categories, + metacriticScore: $data['metacritic']['score'] ?? null, + metacriticUrl: $data['metacritic']['url'] ?? null, + price: $price, + platforms: $platforms, + requirements: $requirements, + dlcIds: $data['dlc'] ?? [], + achievementCount: $data['achievements']['total'] ?? null, + recommendationCount: $data['recommendations']['total'] ?? null, + website: $data['website'] ?? null, + supportUrl: $data['support_info']['url'] ?? null, + storeUrl: 'https://store.steampowered.com/app/' . $appId, + ); + } + + /** + * Convert to array format compatible with existing GamesInfo model. + */ + public function toGamesInfoArray(): array + { + return [ + 'title' => $this->title, + 'asin' => (string) $this->steamId, + 'url' => $this->storeUrl, + 'publisher' => $this->publisher ?? 'Unknown', + 'releasedate' => $this->releaseDate, + 'review' => $this->description ?? 'No description available', + 'cover' => !empty($this->coverUrl) ? 1 : 0, + 'backdrop' => !empty($this->backdropUrl) ? 1 : 0, + 'trailer' => $this->trailerUrl ?? '', + 'classused' => 'Steam', + 'esrb' => $this->metacriticScore !== null ? (string) $this->metacriticScore : 'Not Rated', + 'coverurl' => $this->coverUrl, + 'backdropurl' => $this->backdropUrl, + 'genres' => implode(',', $this->genres), + ]; + } + + /** + * Check if this is a game (not DLC, video, etc.). + */ + public function isGame(): bool + { + return in_array($this->type, ['game', 'demo'], true); + } + + /** + * Check if game is free. + */ + public function isFree(): bool + { + return $this->price !== null && $this->price->final <= 0; + } + + /** + * Get primary genre. + */ + public function getPrimaryGenre(): ?string + { + return $this->genres[0] ?? null; + } + + /** + * Check if game supports a platform. + */ + public function supportsPlatform(string $platform): bool + { + return in_array($platform, $this->platforms, true); + } + + /** + * Check if game has multiplayer. + */ + public function hasMultiplayer(): bool + { + $multiplayerCategories = ['Multi-player', 'Online Multi-Player', 'Online Co-op', 'Local Multi-Player', 'Local Co-op']; + return !empty(array_intersect($multiplayerCategories, $this->categories)); + } + + /** + * Check if game supports Steam Workshop. + */ + public function hasSteamWorkshop(): bool + { + return in_array('Steam Workshop', $this->categories, true); + } + + /** + * Get formatted genres string. + */ + public function getGenresString(): string + { + return implode(', ', $this->genres); + } +} + diff --git a/app/Support/DTOs/SteamPriceData.php b/app/Support/DTOs/SteamPriceData.php new file mode 100644 index 000000000..a2c2668de --- /dev/null +++ b/app/Support/DTOs/SteamPriceData.php @@ -0,0 +1,102 @@ +discountPercent > 0; + } + + /** + * Check if free. + */ + public function isFree(): bool + { + return $this->final <= 0; + } + + /** + * Get savings amount. + */ + public function getSavings(): float + { + return max(0, $this->initial - $this->final); + } + + /** + * Get formatted display price. + */ + public function getDisplayPrice(): string + { + if ($this->formattedPrice !== null) { + return $this->formattedPrice; + } + + if ($this->isFree()) { + return 'Free'; + } + + return sprintf('%s %.2f', $this->currency, $this->final); + } + + /** + * Convert to array. + */ + public function toArray(): array + { + return [ + 'currency' => $this->currency, + 'initial' => $this->initial, + 'final' => $this->final, + 'discount_percent' => $this->discountPercent, + 'formatted' => $this->formattedPrice, + ]; + } +} + diff --git a/tests/Feature/SteamServiceTest.php b/tests/Feature/SteamServiceTest.php new file mode 100644 index 000000000..b72693f2a --- /dev/null +++ b/tests/Feature/SteamServiceTest.php @@ -0,0 +1,453 @@ +service = new SteamService(); + Cache::flush(); + } + + // ========================================== + // Search Tests with Mocked Database + // ========================================== + + public function test_search_finds_exact_match_in_database(): void + { + // Insert test data + SteamApp::query()->insert([ + ['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'], + ['appid' => 1091500, 'name' => 'Cyberpunk 2077'], + ['appid' => 1245620, 'name' => 'ELDEN RING'], + ]); + + // Search should find exact match + $result = $this->service->search('Cyberpunk 2077'); + + $this->assertNotNull($result); + $this->assertSame(1091500, $result); + } + + public function test_search_finds_match_from_scene_release_name(): void + { + SteamApp::query()->insert([ + ['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'], + ]); + + $result = $this->service->search('The.Witcher.3.Wild.Hunt-CODEX'); + + $this->assertNotNull($result); + $this->assertSame(292030, $result); + } + + public function test_search_returns_null_for_no_match(): void + { + SteamApp::query()->insert([ + ['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'], + ]); + + $result = $this->service->search('NonExistent Game Title XYZ'); + + $this->assertNull($result); + } + + public function test_search_caches_successful_result(): void + { + SteamApp::query()->insert([ + ['appid' => 1091500, 'name' => 'Cyberpunk 2077'], + ]); + + // First search + $result1 = $this->service->search('Cyberpunk 2077'); + $this->assertSame(1091500, $result1); + + // Delete from database + SteamApp::query()->where('appid', 1091500)->delete(); + + // Second search should return cached result + $result2 = $this->service->search('Cyberpunk 2077'); + $this->assertSame(1091500, $result2); + } + + public function test_search_caches_failed_lookup(): void + { + // Search for non-existent game + $this->service->search('NonExistent Game 12345'); + + // Add the game to database + SteamApp::query()->insert([ + ['appid' => 99999, 'name' => 'NonExistent Game 12345'], + ]); + + // Search should still return null due to cache + $result = $this->service->search('NonExistent Game 12345'); + $this->assertNull($result); + } + + public function test_search_multiple_returns_collection(): void + { + SteamApp::query()->insert([ + ['appid' => 292030, 'name' => 'The Witcher 3: Wild Hunt'], + ['appid' => 20900, 'name' => 'The Witcher'], + ['appid' => 20920, 'name' => 'The Witcher 2: Assassins of Kings'], + ]); + + $results = $this->service->searchMultiple('Witcher', 10); + + $this->assertNotEmpty($results); + $this->assertGreaterThanOrEqual(1, $results->count()); + } + + // ========================================== + // API Tests with HTTP Mocking + // ========================================== + + public function test_get_game_details_returns_formatted_data(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '1091500' => [ + 'success' => true, + 'data' => [ + 'type' => 'game', + 'name' => 'Cyberpunk 2077', + 'steam_appid' => 1091500, + 'short_description' => 'An open-world RPG set in Night City.', + 'detailed_description' => 'Full description here...', + 'about_the_game' => 'About the game...', + 'header_image' => 'https://cdn.steam.com/header.jpg', + 'background' => 'https://cdn.steam.com/background.jpg', + 'publishers' => ['CD PROJEKT RED'], + 'developers' => ['CD PROJEKT RED'], + 'genres' => [ + ['id' => '1', 'description' => 'Action'], + ['id' => '3', 'description' => 'RPG'], + ], + 'categories' => [ + ['id' => 2, 'description' => 'Single-player'], + ], + 'release_date' => [ + 'coming_soon' => false, + 'date' => 'Dec 10, 2020', + ], + 'metacritic' => [ + 'score' => 86, + 'url' => 'https://www.metacritic.com/game/cyberpunk-2077', + ], + 'platforms' => [ + 'windows' => true, + 'mac' => false, + 'linux' => false, + ], + 'price_overview' => [ + 'currency' => 'USD', + 'initial' => 5999, + 'final' => 2999, + 'discount_percent' => 50, + 'final_formatted' => '$29.99', + ], + 'screenshots' => [ + [ + 'id' => 0, + 'path_thumbnail' => 'https://cdn.steam.com/ss_thumb.jpg', + 'path_full' => 'https://cdn.steam.com/ss_full.jpg', + ], + ], + 'movies' => [ + [ + 'id' => 256123, + 'name' => 'Launch Trailer', + 'thumbnail' => 'https://cdn.steam.com/movie_thumb.jpg', + 'mp4' => [ + '480' => 'https://cdn.steam.com/movie_480.mp4', + 'max' => 'https://cdn.steam.com/movie_max.mp4', + ], + ], + ], + 'achievements' => [ + 'total' => 44, + ], + 'recommendations' => [ + 'total' => 500000, + ], + ], + ], + ]), + ]); + + $result = $this->service->getGameDetails(1091500); + + $this->assertIsArray($result); + $this->assertSame('Cyberpunk 2077', $result['title']); + $this->assertSame(1091500, $result['steamid']); + $this->assertSame('game', $result['type']); + $this->assertSame('CD PROJEKT RED', $result['publisher']); + $this->assertSame(['CD PROJEKT RED'], $result['developers']); + $this->assertSame(86, $result['metacritic_score']); + $this->assertStringContainsString('Action', $result['genres']); + $this->assertContains('Windows', $result['platforms']); + $this->assertNotEmpty($result['screenshots']); + $this->assertNotEmpty($result['movies']); + $this->assertSame(44, $result['achievements']); + } + + public function test_get_game_details_returns_false_on_api_failure(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '99999' => [ + 'success' => false, + ], + ]), + ]); + + $result = $this->service->getGameDetails(99999); + + $this->assertFalse($result); + } + + public function test_get_game_details_caches_result(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '1091500' => [ + 'success' => true, + 'data' => [ + 'type' => 'game', + 'name' => 'Cyberpunk 2077', + 'steam_appid' => 1091500, + ], + ], + ]), + ]); + + // First call + $result1 = $this->service->getGameDetails(1091500); + $this->assertSame('Cyberpunk 2077', $result1['title']); + + // Second call should use cache (no HTTP request) + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '1091500' => [ + 'success' => true, + 'data' => [ + 'type' => 'game', + 'name' => 'Different Name', + 'steam_appid' => 1091500, + ], + ], + ]), + ]); + + $result2 = $this->service->getGameDetails(1091500); + $this->assertSame('Cyberpunk 2077', $result2['title']); // Still cached + } + + public function test_get_reviews_summary(): void + { + Http::fake([ + 'store.steampowered.com/api/appreviews/*' => Http::response([ + 'success' => 1, + 'query_summary' => [ + 'total_positive' => 400000, + 'total_negative' => 100000, + 'total_reviews' => 500000, + 'review_score' => 8, + 'review_score_desc' => 'Very Positive', + ], + ]), + ]); + + $result = $this->service->getReviewsSummary(1091500); + + $this->assertIsArray($result); + $this->assertSame(400000, $result['total_positive']); + $this->assertSame(100000, $result['total_negative']); + $this->assertSame(500000, $result['total_reviews']); + $this->assertSame('Very Positive', $result['review_score_desc']); + } + + public function test_get_full_app_list(): void + { + Http::fake([ + 'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([ + 'applist' => [ + 'apps' => [ + ['appid' => 10, 'name' => 'Counter-Strike'], + ['appid' => 20, 'name' => 'Team Fortress Classic'], + ['appid' => 30, 'name' => 'Day of Defeat'], + ], + ], + ]), + ]); + + $result = $this->service->getFullAppList(); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertSame(10, $result[0]['appid']); + $this->assertSame('Counter-Strike', $result[0]['name']); + } + + // ========================================== + // Populate Apps Table Tests + // ========================================== + + public function test_populate_steam_apps_table(): void + { + Http::fake([ + 'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([ + 'applist' => [ + 'apps' => [ + ['appid' => 10, 'name' => 'Counter-Strike'], + ['appid' => 20, 'name' => 'Team Fortress Classic'], + ['appid' => 30, 'name' => 'Day of Defeat'], + ], + ], + ]), + ]); + + $stats = $this->service->populateSteamAppsTable(); + + $this->assertArrayHasKey('inserted', $stats); + $this->assertArrayHasKey('skipped', $stats); + $this->assertArrayHasKey('errors', $stats); + $this->assertSame(3, $stats['inserted']); + + // Verify data in database + $this->assertDatabaseHas('steam_apps', ['appid' => 10, 'name' => 'Counter-Strike']); + $this->assertDatabaseHas('steam_apps', ['appid' => 20, 'name' => 'Team Fortress Classic']); + } + + public function test_populate_steam_apps_table_skips_duplicates(): void + { + // Pre-insert some apps + SteamApp::query()->insert([ + ['appid' => 10, 'name' => 'Counter-Strike'], + ]); + + Http::fake([ + 'api.steampowered.com/ISteamApps/GetAppList/v2/*' => Http::response([ + 'applist' => [ + 'apps' => [ + ['appid' => 10, 'name' => 'Counter-Strike'], + ['appid' => 20, 'name' => 'Team Fortress Classic'], + ], + ], + ]), + ]); + + $stats = $this->service->populateSteamAppsTable(); + + $this->assertSame(1, $stats['inserted']); + $this->assertSame(1, $stats['skipped']); + } + + // ========================================== + // Price Handling Tests + // ========================================== + + public function test_handles_free_game(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '570' => [ + 'success' => true, + 'data' => [ + 'type' => 'game', + 'name' => 'Dota 2', + 'steam_appid' => 570, + 'is_free' => true, + ], + ], + ]), + ]); + + $result = $this->service->getGameDetails(570); + + $this->assertIsArray($result); + $this->assertNotNull($result['price']); + $this->assertSame(0.0, $result['price']['final']); + $this->assertSame('Free', $result['price']['final_formatted']); + } + + public function test_handles_game_on_sale(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => Http::response([ + '1091500' => [ + 'success' => true, + 'data' => [ + 'type' => 'game', + 'name' => 'Cyberpunk 2077', + 'steam_appid' => 1091500, + 'price_overview' => [ + 'currency' => 'USD', + 'initial' => 5999, + 'final' => 2999, + 'discount_percent' => 50, + 'final_formatted' => '$29.99', + ], + ], + ], + ]), + ]); + + $result = $this->service->getGameDetails(1091500); + + $this->assertSame(59.99, $result['price']['initial']); + $this->assertSame(29.99, $result['price']['final']); + $this->assertSame(50, $result['price']['discount_percent']); + } + + // ========================================== + // Edge Cases + // ========================================== + + public function test_handles_empty_search_term(): void + { + $result = $this->service->search(''); + $this->assertNull($result); + + $result = $this->service->search(' '); + $this->assertNull($result); + } + + public function test_clean_title_handles_url_encoded_input(): void + { + $result = $this->service->cleanTitle('The%20Witcher%203'); + $this->assertSame('The Witcher 3', $result); + } + + public function test_handles_api_timeout(): void + { + Http::fake([ + 'store.steampowered.com/api/appdetails*' => function () { + throw new \Illuminate\Http\Client\ConnectionException('Connection timed out'); + }, + ]); + + $result = $this->service->getGameDetails(1091500); + $this->assertFalse($result); + } +} + diff --git a/tests/Unit/SteamDTOsTest.php b/tests/Unit/SteamDTOsTest.php new file mode 100644 index 000000000..555507664 --- /dev/null +++ b/tests/Unit/SteamDTOsTest.php @@ -0,0 +1,306 @@ + 'USD', + 'initial' => 5999, + 'final' => 2999, + 'discount_percent' => 50, + 'final_formatted' => '$29.99', + ]; + + $price = SteamPriceData::fromApiResponse($data); + + $this->assertSame('USD', $price->currency); + $this->assertSame(59.99, $price->initial); + $this->assertSame(29.99, $price->final); + $this->assertSame(50, $price->discountPercent); + $this->assertSame('$29.99', $price->formattedPrice); + } + + public function test_price_data_free(): void + { + $price = SteamPriceData::free(); + + $this->assertTrue($price->isFree()); + $this->assertSame(0.0, $price->final); + $this->assertSame('Free', $price->formattedPrice); + } + + public function test_price_data_is_on_sale(): void + { + $onSale = new SteamPriceData('USD', 59.99, 29.99, 50); + $notOnSale = new SteamPriceData('USD', 59.99, 59.99, 0); + + $this->assertTrue($onSale->isOnSale()); + $this->assertFalse($notOnSale->isOnSale()); + } + + public function test_price_data_get_savings(): void + { + $price = new SteamPriceData('USD', 59.99, 29.99, 50); + + $this->assertEqualsWithDelta(30.00, $price->getSavings(), 0.01); + } + + public function test_price_data_get_display_price(): void + { + $priceWithFormat = new SteamPriceData('USD', 59.99, 29.99, 50, '$29.99'); + $priceWithoutFormat = new SteamPriceData('USD', 59.99, 29.99, 50); + $freePrice = SteamPriceData::free(); + + $this->assertSame('$29.99', $priceWithFormat->getDisplayPrice()); + $this->assertSame('USD 29.99', $priceWithoutFormat->getDisplayPrice()); + $this->assertSame('Free', $freePrice->getDisplayPrice()); + } + + public function test_price_data_to_array(): void + { + $price = new SteamPriceData('EUR', 49.99, 24.99, 50, '24,99€'); + $array = $price->toArray(); + + $this->assertSame('EUR', $array['currency']); + $this->assertSame(49.99, $array['initial']); + $this->assertSame(24.99, $array['final']); + $this->assertSame(50, $array['discount_percent']); + $this->assertSame('24,99€', $array['formatted']); + } + + // ========================================== + // SteamGameData Tests + // ========================================== + + public function test_game_data_from_api_response(): void + { + $data = [ + 'type' => 'game', + 'name' => 'Cyberpunk 2077', + 'steam_appid' => 1091500, + 'short_description' => 'An open-world RPG.', + 'detailed_description' => 'Full description...', + 'about_the_game' => 'About...', + 'header_image' => 'https://cdn.steam.com/header.jpg', + 'background' => 'https://cdn.steam.com/bg.jpg', + 'publishers' => ['CD PROJEKT RED'], + 'developers' => ['CD PROJEKT RED', 'CD PROJEKT'], + 'genres' => [ + ['id' => '1', 'description' => 'Action'], + ['id' => '3', 'description' => 'RPG'], + ], + 'categories' => [ + ['id' => 2, 'description' => 'Single-player'], + ['id' => 36, 'description' => 'Online Multi-Player'], + ], + 'release_date' => [ + 'coming_soon' => false, + 'date' => 'Dec 10, 2020', + ], + 'metacritic' => [ + 'score' => 86, + 'url' => 'https://www.metacritic.com/game/cyberpunk-2077', + ], + 'platforms' => [ + 'windows' => true, + 'mac' => false, + 'linux' => true, + ], + 'is_free' => false, + 'screenshots' => [ + [ + 'id' => 0, + 'path_thumbnail' => 'https://cdn.steam.com/ss_thumb.jpg', + 'path_full' => 'https://cdn.steam.com/ss_full.jpg', + ], + ], + 'movies' => [ + [ + 'id' => 256123, + 'name' => 'Launch Trailer', + 'thumbnail' => 'https://cdn.steam.com/movie_thumb.jpg', + 'mp4' => [ + '480' => 'https://cdn.steam.com/movie_480.mp4', + 'max' => 'https://cdn.steam.com/movie_max.mp4', + ], + ], + ], + 'achievements' => ['total' => 44], + 'recommendations' => ['total' => 500000], + 'website' => 'https://www.cyberpunk.net', + 'dlc' => [1234, 5678], + ]; + + $game = SteamGameData::fromApiResponse($data, 1091500); + + $this->assertSame(1091500, $game->steamId); + $this->assertSame('Cyberpunk 2077', $game->title); + $this->assertSame('game', $game->type); + $this->assertSame('An open-world RPG.', $game->description); + $this->assertSame('CD PROJEKT RED', $game->publisher); + $this->assertCount(2, $game->developers); + $this->assertContains('Action', $game->genres); + $this->assertContains('RPG', $game->genres); + $this->assertContains('Windows', $game->platforms); + $this->assertContains('Linux', $game->platforms); + $this->assertNotContains('Mac', $game->platforms); + $this->assertSame(86, $game->metacriticScore); + $this->assertCount(1, $game->screenshots); + $this->assertCount(1, $game->movies); + $this->assertSame(44, $game->achievementCount); + $this->assertSame(500000, $game->recommendationCount); + $this->assertCount(2, $game->dlcIds); + } + + public function test_game_data_is_game(): void + { + $game = new SteamGameData(1, 'Test', 'game'); + $dlc = new SteamGameData(2, 'Test DLC', 'dlc'); + $demo = new SteamGameData(3, 'Test Demo', 'demo'); + $video = new SteamGameData(4, 'Test Video', 'video'); + + $this->assertTrue($game->isGame()); + $this->assertFalse($dlc->isGame()); + $this->assertTrue($demo->isGame()); + $this->assertFalse($video->isGame()); + } + + public function test_game_data_is_free(): void + { + $freeGame = new SteamGameData(1, 'Free Game', price: SteamPriceData::free()); + $paidGame = new SteamGameData(2, 'Paid Game', price: new SteamPriceData('USD', 59.99, 59.99, 0)); + $noPriceGame = new SteamGameData(3, 'No Price Game'); + + $this->assertTrue($freeGame->isFree()); + $this->assertFalse($paidGame->isFree()); + $this->assertFalse($noPriceGame->isFree()); // null price = not free + } + + public function test_game_data_get_primary_genre(): void + { + $gameWithGenres = new SteamGameData(1, 'Test', genres: ['Action', 'RPG', 'Adventure']); + $gameNoGenres = new SteamGameData(2, 'Test', genres: []); + + $this->assertSame('Action', $gameWithGenres->getPrimaryGenre()); + $this->assertNull($gameNoGenres->getPrimaryGenre()); + } + + public function test_game_data_supports_platform(): void + { + $game = new SteamGameData(1, 'Test', platforms: ['Windows', 'Linux']); + + $this->assertTrue($game->supportsPlatform('Windows')); + $this->assertTrue($game->supportsPlatform('Linux')); + $this->assertFalse($game->supportsPlatform('Mac')); + } + + public function test_game_data_has_multiplayer(): void + { + $multiplayerGame = new SteamGameData(1, 'Test', categories: ['Single-player', 'Online Multi-Player']); + $singlePlayerGame = new SteamGameData(2, 'Test', categories: ['Single-player']); + $coopGame = new SteamGameData(3, 'Test', categories: ['Online Co-op']); + + $this->assertTrue($multiplayerGame->hasMultiplayer()); + $this->assertFalse($singlePlayerGame->hasMultiplayer()); + $this->assertTrue($coopGame->hasMultiplayer()); + } + + public function test_game_data_has_steam_workshop(): void + { + $withWorkshop = new SteamGameData(1, 'Test', categories: ['Steam Workshop', 'Single-player']); + $withoutWorkshop = new SteamGameData(2, 'Test', categories: ['Single-player']); + + $this->assertTrue($withWorkshop->hasSteamWorkshop()); + $this->assertFalse($withoutWorkshop->hasSteamWorkshop()); + } + + public function test_game_data_get_genres_string(): void + { + $game = new SteamGameData(1, 'Test', genres: ['Action', 'RPG', 'Adventure']); + + $this->assertSame('Action, RPG, Adventure', $game->getGenresString()); + } + + public function test_game_data_to_games_info_array(): void + { + $game = new SteamGameData( + steamId: 1091500, + title: 'Cyberpunk 2077', + type: 'game', + description: 'An open-world RPG.', + coverUrl: 'https://cdn.steam.com/header.jpg', + backdropUrl: 'https://cdn.steam.com/bg.jpg', + trailerUrl: 'https://cdn.steam.com/trailer.mp4', + publisher: 'CD PROJEKT RED', + releaseDate: '2020-12-10', + genres: ['Action', 'RPG'], + metacriticScore: 86, + storeUrl: 'https://store.steampowered.com/app/1091500', + ); + + $array = $game->toGamesInfoArray(); + + $this->assertSame('Cyberpunk 2077', $array['title']); + $this->assertSame('1091500', $array['asin']); + $this->assertSame('https://store.steampowered.com/app/1091500', $array['url']); + $this->assertSame('CD PROJEKT RED', $array['publisher']); + $this->assertSame('2020-12-10', $array['releasedate']); + $this->assertSame('An open-world RPG.', $array['review']); + $this->assertSame(1, $array['cover']); + $this->assertSame(1, $array['backdrop']); + $this->assertSame('https://cdn.steam.com/trailer.mp4', $array['trailer']); + $this->assertSame('Steam', $array['classused']); + $this->assertSame('86', $array['esrb']); + $this->assertSame('Action,RPG', $array['genres']); + } + + public function test_game_data_handles_missing_optional_fields(): void + { + $data = [ + 'type' => 'game', + 'name' => 'Minimal Game', + 'steam_appid' => 12345, + ]; + + $game = SteamGameData::fromApiResponse($data, 12345); + + $this->assertSame(12345, $game->steamId); + $this->assertSame('Minimal Game', $game->title); + $this->assertNull($game->description); + $this->assertNull($game->publisher); + $this->assertEmpty($game->developers); + $this->assertEmpty($game->genres); + $this->assertEmpty($game->platforms); + $this->assertNull($game->price); + $this->assertNull($game->metacriticScore); + } + + public function test_game_data_immutability(): void + { + $game = new SteamGameData(1, 'Test'); + + // This should be a compile error in strict mode, or readonly + // We just verify the properties are accessible but not writable + $this->assertSame(1, $game->steamId); + $this->assertSame('Test', $game->title); + + // The readonly keyword ensures immutability at compile time + } +} + diff --git a/tests/Unit/SteamServiceTest.php b/tests/Unit/SteamServiceTest.php new file mode 100644 index 000000000..32496a4cb --- /dev/null +++ b/tests/Unit/SteamServiceTest.php @@ -0,0 +1,357 @@ +newInstanceWithoutConstructor(); + } + + private function invokeMethod(object $obj, string $method, array $args = []): mixed + { + $ref = new ReflectionClass($obj); + $m = $ref->getMethod($method); + $m->setAccessible(true); + return $m->invokeArgs($obj, $args); + } + + // ========================================== + // Title Cleaning Tests + // ========================================== + + #[DataProvider('cleanTitleProvider')] + public function test_clean_title(string $input, string $expected): void + { + $service = $this->getInstance(); + $result = $service->cleanTitle($input); + + $this->assertSame($expected, $result, "Failed for input: {$input}"); + } + + public static function cleanTitleProvider(): array + { + return [ + // Basic scene releases + ['Cyberpunk.2077-GOG', 'Cyberpunk 2077'], + ['The.Witcher.3.Wild.Hunt-CODEX', 'The Witcher 3 Wild Hunt'], + ['Elden.Ring-EMPRESS', 'Elden Ring'], + ['Baldurs.Gate.3-SKIDROW', 'Baldurs Gate 3'], + + // With version numbers + ['Starfield.v1.7.29-FLT', 'Starfield'], + ['Cyberpunk.2077.v2.1.0.1-GOG', 'Cyberpunk 2077'], + ['Game.v1.2.3.4.5-CODEX', 'Game'], + + // Edition tags + ['ELDEN RING [v1.08 + DLCs] - EMPRESS', 'ELDEN RING'], + ['The Witcher 3 Wild Hunt GOTY - GOG', 'The Witcher 3 Wild Hunt'], + ['Horizon Zero Dawn Complete Edition - CODEX', 'Horizon Zero Dawn'], + ['Red Dead Redemption 2 Ultimate Edition-EMPRESS', 'Red Dead Redemption 2'], + + // FitGirl-style releases - may include some noise + ['[FitGirl] Red.Dead.Redemption.2.v1.0.1436.28.Repack', 'Red Dead Redemption 2'], + + // Complex releases - these may retain some noise + ['Resident.Evil.4.Remake.REPACK-DODI', 'Resident Evil 4 Remake'], + + // Special characters + ["Assassin's.Creed.Valhalla-CODEX", "Assassin's Creed Valhalla"], + ['Tom.Clancys.Ghost.Recon.Breakpoint-EMPRESS', 'Tom Clancys Ghost Recon Breakpoint'], + + // Already clean + ['Cyberpunk 2077', 'Cyberpunk 2077'], + ['The Witcher 3 Wild Hunt', 'The Witcher 3 Wild Hunt'], + + // Edge cases + ['', ''], + [' ', ''], + ]; + } + + // ========================================== + // Title Normalization Tests + // ========================================== + + #[DataProvider('normalizeTitleProvider')] + public function test_normalize_title(string $input, string $expectedContains, string $expectedNotContains = ''): void + { + $service = $this->getInstance(); + $result = $this->invokeMethod($service, 'normalizeTitle', [$input]); + + if ($expectedContains !== '') { + $this->assertStringContainsString($expectedContains, $result, "Expected '{$expectedContains}' in result '{$result}'"); + } + + if ($expectedNotContains !== '') { + $this->assertStringNotContainsString($expectedNotContains, $result, "Expected '{$expectedNotContains}' NOT in result '{$result}'"); + } + } + + public static function normalizeTitleProvider(): array + { + return [ + ['The Witcher 3: Wild Hunt', 'witcher', 'the'], + ['ELDEN RING', 'elden ring', ''], + ['Red Dead Redemption 2', 'red dead redemption 2', ''], + ['Cyberpunk 2077 GOTY Edition', 'cyberpunk 2077', 'goty'], + ['Assassin\'s Creed IV: Black Flag', 'assassin', ''], + ]; + } + + // ========================================== + // Roman Numeral Replacement Tests + // ========================================== + + #[DataProvider('romanNumeralProvider')] + public function test_replace_roman_numerals(string $input, string $expected): void + { + $service = $this->getInstance(); + $result = $this->invokeMethod($service, 'replaceRomanNumerals', [$input]); + + $this->assertSame($expected, $result); + } + + public static function romanNumeralProvider(): array + { + return [ + ['final fantasy vii', 'final fantasy 7'], + ['final fantasy viii', 'final fantasy 8'], + ['resident evil iv', 'resident evil 4'], + ['witcher iii', 'witcher 3'], + ['gta v', 'gta 5'], + ['civilization vi', 'civilization 6'], + ['elder scrolls iv oblivion', 'elder scrolls 4 oblivion'], + ['dark souls ii', 'dark souls 2'], + ['street fighter ii', 'street fighter 2'], + ['assassins creed ii', 'assassins creed 2'], + // Should not replace standalone 'i' (common in titles) + ['i robot', 'i robot'], + // Should handle multiple numerals + ['title ii and iii', 'title 2 and 3'], + ]; + } + + // ========================================== + // Query Variants Generation Tests + // ========================================== + + public function test_generate_query_variants_includes_base_title(): void + { + $service = $this->getInstance(); + $variants = $this->invokeMethod($service, 'generateQueryVariants', ['The Witcher 3: Wild Hunt - Game of the Year Edition']); + + $this->assertIsArray($variants); + $this->assertNotEmpty($variants); + + // Should include variants without edition tags + $found = false; + foreach ($variants as $v) { + if (stripos($v, 'game of the year') === false && stripos($v, 'witcher') !== false) { + $found = true; + break; + } + } + $this->assertTrue($found, 'Expected base title variant without edition tags'); + } + + public function test_generate_query_variants_handles_colon_titles(): void + { + $service = $this->getInstance(); + $variants = $this->invokeMethod($service, 'generateQueryVariants', ["Assassin's Creed IV: Black Flag"]); + + $this->assertIsArray($variants); + + // Should include left side of colon + $foundLeft = false; + foreach ($variants as $v) { + if (stripos($v, 'black flag') === false && stripos($v, "Assassin's Creed") !== false) { + $foundLeft = true; + break; + } + } + $this->assertTrue($foundLeft, 'Expected left-side of colon as a variant'); + } + + // ========================================== + // Title Scoring Tests + // ========================================== + + #[DataProvider('scoreTitleProvider')] + public function test_score_title(string $candidate, string $original, float $minScore): void + { + $service = $this->getInstance(); + $score = $this->invokeMethod($service, 'scoreTitle', [$candidate, $original]); + + $this->assertIsFloat($score); + $this->assertGreaterThanOrEqual($minScore, $score, "Score {$score} should be >= {$minScore} for '{$candidate}' vs '{$original}'"); + } + + public static function scoreTitleProvider(): array + { + return [ + // Exact matches + ['Cyberpunk 2077', 'Cyberpunk 2077', 100.0], + ['The Witcher 3: Wild Hunt', 'The Witcher 3: Wild Hunt', 100.0], + + // Close matches (should score high) + ['The Witcher 3: Wild Hunt', 'The.Witcher.3.Wild.Hunt-CODEX', 90.0], + ['Elden Ring', 'ELDEN RING - Deluxe Edition - EMPRESS', 70.0], + ['Cyberpunk 2077', 'Cyberpunk.2077.v2.1-GOG', 70.0], + ['Red Dead Redemption 2', '[FitGirl] Red.Dead.Redemption.2.Repack', 80.0], + + // Partial matches + ['Witcher 3', 'The Witcher 3: Wild Hunt', 50.0], + + // Non-matches (should score low) + ['Cyberpunk 2077', 'Elden Ring', 0.0], + ]; + } + + public function test_score_title_perfect_normalized_match(): void + { + $service = $this->getInstance(); + + // These should normalize to the same thing + $score = $this->invokeMethod($service, 'scoreTitle', [ + 'The Witcher 3: Wild Hunt', + 'The.Witcher.III.Wild.Hunt.GOTY-CODEX' + ]); + + // With roman numeral conversion and normalization, these should be very similar + $this->assertGreaterThanOrEqual(85.0, $score); + } + + // ========================================== + // LIKE Pattern Building Tests + // ========================================== + + public function test_build_like_pattern(): void + { + $service = $this->getInstance(); + + $pattern = $this->invokeMethod($service, 'buildLikePattern', ['Resident Evil 4']); + $this->assertStringStartsWith('%', $pattern); + $this->assertStringEndsWith('%', $pattern); + $this->assertStringContainsString('resident', strtolower($pattern)); + $this->assertStringContainsString('evil', strtolower($pattern)); + $this->assertStringContainsString('4', $pattern); + } + + public function test_build_like_pattern_handles_roman_numerals(): void + { + $service = $this->getInstance(); + + $pattern = $this->invokeMethod($service, 'buildLikePattern', ['Resident Evil VII Biohazard']); + $this->assertStringContainsString('7', $pattern); + $this->assertStringNotContainsString('vii', strtolower($pattern)); + } + + // ========================================== + // Tokenization Tests + // ========================================== + + public function test_tokenize(): void + { + $service = $this->getInstance(); + + $tokens = $this->invokeMethod($service, 'tokenize', ['The Witcher 3 Wild Hunt']); + $this->assertIsArray($tokens); + $this->assertContains('witcher', $tokens); + $this->assertContains('3', $tokens); + $this->assertContains('wild', $tokens); + $this->assertContains('hunt', $tokens); + } + + public function test_tokenize_deduplicates(): void + { + $service = $this->getInstance(); + + $tokens = $this->invokeMethod($service, 'tokenize', ['game game game']); + $this->assertCount(1, $tokens); + $this->assertSame(['game'], $tokens); + } + + // ========================================== + // Strip Edition Tags Tests + // ========================================== + + #[DataProvider('stripEditionTagsProvider')] + public function test_strip_edition_tags(string $input, string $expected): void + { + $service = $this->getInstance(); + $result = $this->invokeMethod($service, 'stripEditionTags', [$input]); + + $this->assertSame($expected, $result); + } + + public static function stripEditionTagsProvider(): array + { + return [ + ['Cyberpunk 2077 Ultimate Edition', 'Cyberpunk 2077'], + ['Horizon Zero Dawn', 'Horizon Zero Dawn'], // No tags + ]; + } + + // ========================================== + // Integration-style Tests (Method Combinations) + // ========================================== + + public function test_full_matching_workflow(): void + { + $service = $this->getInstance(); + + // Simulate a scene release name + $releaseName = 'The.Witcher.III.Wild.Hunt.GOTY.Edition-CODEX'; + + // Clean it + $clean = $service->cleanTitle($releaseName); + $this->assertStringNotContainsString('.', $clean); + $this->assertStringNotContainsString('CODEX', $clean); + + // Generate variants + $variants = $this->invokeMethod($service, 'generateQueryVariants', [$clean]); + $this->assertNotEmpty($variants); + + // Normalize for matching + $normalized = $this->invokeMethod($service, 'normalizeTitle', [$clean]); + $this->assertStringContainsString('witcher', $normalized); + $this->assertStringContainsString('3', $normalized); // Roman numeral converted + } + + public function test_matching_workflow_with_various_formats(): void + { + $testCases = [ + 'Cyberpunk.2077-GOG' => 'cyberpunk 2077', + 'ELDEN RING [v1.08 + DLCs] - EMPRESS' => 'elden ring', + '[FitGirl] Red.Dead.Redemption.2.v1.0.1436.28.Repack' => 'red dead redemption 2', + 'Baldurs_Gate_3_v1.0.2_MULTI12-EMPRESS' => 'baldurs gate 3', + ]; + + $service = $this->getInstance(); + + foreach ($testCases as $input => $expectedInNormalized) { + $clean = $service->cleanTitle($input); + $normalized = $this->invokeMethod($service, 'normalizeTitle', [$clean]); + + $this->assertStringContainsString( + $expectedInNormalized, + $normalized, + "Failed for input: {$input}" + ); + } + } +} +