From 3502c3e85b7cabdcb785f9d8ceb83d18a36fedb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Mon, 7 Sep 2026 18:34:51 +0200 Subject: [PATCH] fix(models): correct UserSetting import namespace and add test coverage --- .ai/rules/coverage.md | 3 + .ai/rules/index.md | 4 + AGENTS.md | 1 + app/Models/User.php | 2 +- package-lock.json | 2 +- tests/Feature/Auth/EmailNotificationTest.php | 30 ++ tests/Feature/Auth/EmailVerificationTest.php | 9 + tests/Feature/Auth/LoginRequestTest.php | 41 +++ .../Auth/NewPasswordControllerTest.php | 76 +++++ .../Auth/PasswordResetLinkControllerTest.php | 49 +++ .../Auth/VerifyEmailControllerTest.php | 64 ++++ tests/Feature/Games/GameControllerTest.php | 115 +++++++ tests/Feature/Jobs/ScrapeCoverJobTest.php | 86 +++++ .../Feature/Scraper/ScraperControllerTest.php | 104 ++++++ tests/Feature/UploadTest.php | 27 ++ tests/Unit/Data/RomFileDataTest.php | 69 ++++ tests/Unit/Data/SaveFileDataTest.php | 63 ++++ tests/Unit/Data/ScraperResultDataTest.php | 43 +++ tests/Unit/Data/WebDavAccountDataTest.php | 42 +++ tests/Unit/Models/ConsoleTest.php | 55 +++ tests/Unit/Models/GameTest.php | 102 ++++++ tests/Unit/Models/RomFileTest.php | 38 +++ tests/Unit/Models/SaveFileTest.php | 56 +++ tests/Unit/Models/UserSettingTest.php | 34 ++ tests/Unit/Models/UserTest.php | 129 +++++++ .../Providers/HorizonServiceProviderTest.php | 28 ++ ...peScriptTransformerServiceProviderTest.php | 51 +++ .../Repositories/ConsoleRepositoryTest.php | 64 ++++ .../Unit/Repositories/GameRepositoryTest.php | 126 +++++++ .../Repositories/RomFileRepositoryTest.php | 90 +++++ .../Repositories/SettingsRepositoryTest.php | 120 +++++++ tests/Unit/Services/IGDBCoverScraperTest.php | 256 ++++++++++++++ tests/Unit/Services/ScraperServiceTest.php | 201 +++++++++++ tests/Unit/Services/TGDBCoverScraperTest.php | 319 ++++++++++++++++++ 34 files changed, 2497 insertions(+), 2 deletions(-) create mode 100644 .ai/rules/coverage.md create mode 100644 .ai/rules/index.md create mode 100644 tests/Feature/Auth/EmailNotificationTest.php create mode 100644 tests/Feature/Auth/LoginRequestTest.php create mode 100644 tests/Feature/Auth/NewPasswordControllerTest.php create mode 100644 tests/Feature/Auth/PasswordResetLinkControllerTest.php create mode 100644 tests/Feature/Auth/VerifyEmailControllerTest.php create mode 100644 tests/Feature/Games/GameControllerTest.php create mode 100644 tests/Feature/Jobs/ScrapeCoverJobTest.php create mode 100644 tests/Feature/Scraper/ScraperControllerTest.php create mode 100644 tests/Unit/Data/RomFileDataTest.php create mode 100644 tests/Unit/Data/SaveFileDataTest.php create mode 100644 tests/Unit/Data/ScraperResultDataTest.php create mode 100644 tests/Unit/Data/WebDavAccountDataTest.php create mode 100644 tests/Unit/Models/ConsoleTest.php create mode 100644 tests/Unit/Models/GameTest.php create mode 100644 tests/Unit/Models/RomFileTest.php create mode 100644 tests/Unit/Models/SaveFileTest.php create mode 100644 tests/Unit/Models/UserSettingTest.php create mode 100644 tests/Unit/Models/UserTest.php create mode 100644 tests/Unit/Providers/HorizonServiceProviderTest.php create mode 100644 tests/Unit/Providers/TypeScriptTransformerServiceProviderTest.php create mode 100644 tests/Unit/Repositories/ConsoleRepositoryTest.php create mode 100644 tests/Unit/Repositories/GameRepositoryTest.php create mode 100644 tests/Unit/Repositories/RomFileRepositoryTest.php create mode 100644 tests/Unit/Repositories/SettingsRepositoryTest.php create mode 100644 tests/Unit/Services/IGDBCoverScraperTest.php create mode 100644 tests/Unit/Services/ScraperServiceTest.php create mode 100644 tests/Unit/Services/TGDBCoverScraperTest.php diff --git a/.ai/rules/coverage.md b/.ai/rules/coverage.md new file mode 100644 index 0000000..5c6e66a --- /dev/null +++ b/.ai/rules/coverage.md @@ -0,0 +1,3 @@ +# Test Coverage + +After writing or modifying code, run `sail artisan test --coverage` and ensure 100% coverage is achieved. If coverage is below 100%, add or update tests before finalizing changes. diff --git a/.ai/rules/index.md b/.ai/rules/index.md new file mode 100644 index 0000000..db62597 --- /dev/null +++ b/.ai/rules/index.md @@ -0,0 +1,4 @@ +# Rules Index + +## Coverage +- `app/**` → coverage.md diff --git a/AGENTS.md b/AGENTS.md index ad0ddfb..f56184a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +- After writing or modifying code, run `sail artisan test --coverage` and ensure 100% coverage is achieved before finalizing. === inertia-laravel/core rules === diff --git a/app/Models/User.php b/app/Models/User.php index 7021e18..5710947 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,7 @@ namespace App\Models; -use App\Features\Settings\UserSetting; +use App\Features\Settings\Models\UserSetting; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; diff --git a/package-lock.json b/package-lock.json index 1ff8d38..22c1c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "RetroHub", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/tests/Feature/Auth/EmailNotificationTest.php b/tests/Feature/Auth/EmailNotificationTest.php new file mode 100644 index 0000000..aa7b22b --- /dev/null +++ b/tests/Feature/Auth/EmailNotificationTest.php @@ -0,0 +1,30 @@ +create(); + + $this->actingAs($user) + ->post(route('verification.send')) + ->assertRedirect(route('dashboard', absolute: false)); + } + + public function test_unverified_user_gets_notification(): void + { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user) + ->post(route('verification.send')) + ->assertSessionHas('status', 'verification-link-sent'); + } +} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 705570b..d85c933 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -55,4 +55,13 @@ class EmailVerificationTest extends TestCase $this->assertFalse($user->fresh()->hasVerifiedEmail()); } + + public function test_verified_user_is_redirected_from_prompt(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertRedirect(route('dashboard', absolute: false)); + } } diff --git a/tests/Feature/Auth/LoginRequestTest.php b/tests/Feature/Auth/LoginRequestTest.php new file mode 100644 index 0000000..8cd6916 --- /dev/null +++ b/tests/Feature/Auth/LoginRequestTest.php @@ -0,0 +1,41 @@ +create(); + + for ($i = 0; $i < 6; $i++) { + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + } + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ])->assertSessionHasErrors('email'); + } + + public function test_successful_login_redirects(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard', absolute: false)); + + $this->assertAuthenticated(); + } +} diff --git a/tests/Feature/Auth/NewPasswordControllerTest.php b/tests/Feature/Auth/NewPasswordControllerTest.php new file mode 100644 index 0000000..28bae84 --- /dev/null +++ b/tests/Feature/Auth/NewPasswordControllerTest.php @@ -0,0 +1,76 @@ +create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset(): void + { + Notification::fake(); + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + $this->assertTrue( + \Hash::check('new-password', $user->fresh()->password) + ); + + return true; + }); + } + + public function test_password_reset_fails_with_invalid_token(): void + { + $response = $this->post('/reset-password', [ + 'token' => 'invalid-token', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_password_reset_validates_required_fields(): void + { + $response = $this->post('/reset-password', []); + + $response->assertSessionHasErrors(['token', 'email', 'password']); + } +} diff --git a/tests/Feature/Auth/PasswordResetLinkControllerTest.php b/tests/Feature/Auth/PasswordResetLinkControllerTest.php new file mode 100644 index 0000000..9bbf9f0 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetLinkControllerTest.php @@ -0,0 +1,49 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_password_reset_link_can_be_requested(): void + { + Notification::fake(); + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_password_reset_link_fails_with_invalid_email(): void + { + $response = $this->post('/forgot-password', [ + 'email' => 'nonexistent@example.com', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_password_reset_link_validates_email(): void + { + $response = $this->post('/forgot-password', [ + 'email' => '', + ]); + + $response->assertSessionHasErrors('email'); + } +} diff --git a/tests/Feature/Auth/VerifyEmailControllerTest.php b/tests/Feature/Auth/VerifyEmailControllerTest.php new file mode 100644 index 0000000..3d4869f --- /dev/null +++ b/tests/Feature/Auth/VerifyEmailControllerTest.php @@ -0,0 +1,64 @@ +create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_unverified_user_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_email_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/Games/GameControllerTest.php b/tests/Feature/Games/GameControllerTest.php new file mode 100644 index 0000000..ebcec87 --- /dev/null +++ b/tests/Feature/Games/GameControllerTest.php @@ -0,0 +1,115 @@ +create(); + + $this->actingAs($user) + ->get(route('games.edit', 'nonexistent')) + ->assertNotFound(); + } + + public function test_game_update_returns_404_for_invalid_slug(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->put(route('games.update', 'nonexistent'), ['title' => 'Test']) + ->assertNotFound(); + } + + public function test_game_destroy_returns_404_for_invalid_slug(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->delete(route('games.destroy', 'nonexistent')) + ->assertNotFound(); + } + + public function test_game_can_be_deleted_with_rom_files(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + + RomFile::factory()->create([ + 'game_id' => $game->id, + 'file_path' => 'roms/nes/game.nes', + ]); + + Storage::disk('local')->put('roms/nes/game.nes', 'rom-data'); + + $this->actingAs($user) + ->delete(route('games.destroy', $game->slug)) + ->assertRedirect(); + + $this->assertDatabaseMissing('games', ['id' => $game->id]); + } + + public function test_game_can_be_deleted_with_cover(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $console = Console::factory()->create(); + $game = Game::factory()->create([ + 'console_id' => $console->id, + 'cover_path' => 'covers/nes/game-cover.jpg', + ]); + + Storage::disk('local')->put('covers/nes/game-cover.jpg', 'cover-data'); + + $this->actingAs($user) + ->delete(route('games.destroy', $game->slug)) + ->assertRedirect(); + + $this->assertDatabaseMissing('games', ['id' => $game->id]); + } + + public function test_game_download_returns_file(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + + $romFile = RomFile::factory()->create([ + 'game_id' => $game->id, + 'file_path' => 'roms/nes/game.nes', + 'file_name' => 'game.nes', + ]); + + Storage::disk('local')->put('roms/nes/game.nes', 'rom-data'); + + $this->actingAs($user) + ->get(route('games.download', [$game->slug, $romFile->id])) + ->assertOk(); + } + + public function test_game_download_returns_404_for_invalid_rom(): void + { + $user = User::factory()->create(); + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + + $this->actingAs($user) + ->get(route('games.download', [$game->slug, 99999])) + ->assertNotFound(); + } +} diff --git a/tests/Feature/Jobs/ScrapeCoverJobTest.php b/tests/Feature/Jobs/ScrapeCoverJobTest.php new file mode 100644 index 0000000..a431b66 --- /dev/null +++ b/tests/Feature/Jobs/ScrapeCoverJobTest.php @@ -0,0 +1,86 @@ +scraper = $this->createMock(ScraperService::class); + } + + public function test_job_updates_cover_when_match_found(): void + { + Storage::fake('local'); + $console = Console::factory()->create(['slug' => 'nes']); + $game = Game::factory()->create(['console_id' => $console->id]); + $game->load('console'); + + $match = new \stdClass(); + $match->coverUrl = 'https://example.com/cover.jpg'; + + $this->scraper->expects($this->once()) + ->method('search') + ->willReturn(collect([$match])); + + $this->scraper->expects($this->once()) + ->method('downloadCover') + ->willReturn('covers/nes/super-mario-cover.jpg'); + + $job = new ScrapeCoverJob($game); + $job->handle($this->scraper); + + $game->refresh(); + $this->assertEquals('covers/nes/super-mario-cover.jpg', $game->cover_path); + } + + public function test_job_does_nothing_when_no_results(): void + { + $game = Game::factory()->create(); + + $this->scraper->expects($this->once()) + ->method('search') + ->willReturn(collect()); + + $this->scraper->expects($this->never()) + ->method('downloadCover'); + + $job = new ScrapeCoverJob($game); + $job->handle($this->scraper); + + $game->refresh(); + $this->assertNull($game->cover_path); + } + + public function test_job_does_nothing_when_no_cover_url(): void + { + $game = Game::factory()->create(); + + $match = new \stdClass(); + $match->coverUrl = null; + + $this->scraper->expects($this->once()) + ->method('search') + ->willReturn(collect([$match])); + + $this->scraper->expects($this->never()) + ->method('downloadCover'); + + $job = new ScrapeCoverJob($game); + $job->handle($this->scraper); + } +} diff --git a/tests/Feature/Scraper/ScraperControllerTest.php b/tests/Feature/Scraper/ScraperControllerTest.php new file mode 100644 index 0000000..19975cc --- /dev/null +++ b/tests/Feature/Scraper/ScraperControllerTest.php @@ -0,0 +1,104 @@ +getJson(route('scraper.search', ['query' => 'mario'])) + ->assertRedirect(); + } + + public function test_search_validates_query(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson(route('scraper.search', ['query' => ''])) + ->assertSessionHasErrors('query'); + } + + public function test_search_validates_min_query_length(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson(route('scraper.search', ['query' => 'a'])) + ->assertSessionHasErrors('query'); + } + + public function test_search_returns_results(): void + { + $user = User::factory()->create(); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/games' => Http::response([ + [ + 'id' => 1, + 'name' => 'Super Mario Bros', + 'cover' => null, + 'release_dates' => [], + ], + ]), + ]); + + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + $this->actingAs($user) + ->getJson(route('scraper.search', ['query' => 'mario'])) + ->assertOk() + ->assertJsonCount(1, 'results'); + } + + public function test_apply_requires_authentication(): void + { + $this->post(route('scraper.apply', 'some-game')) + ->assertRedirect(route('login')); + } + + public function test_apply_validates_cover_url(): void + { + $user = User::factory()->create(); + $game = Game::factory()->create(); + + $this->actingAs($user) + ->post(route('scraper.apply', $game->slug), []) + ->assertSessionHasErrors('cover_url'); + } + + public function test_apply_downloads_and_updates_cover(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $game = Game::factory()->create(); + $game->load('console'); + + Http::fake([ + 'https://example.com/cover.jpg' => Http::response('image-data'), + ]); + + $this->actingAs($user) + ->post(route('scraper.apply', $game->slug), [ + 'cover_url' => 'https://example.com/cover.jpg', + ]) + ->assertRedirect(); + + $game->refresh(); + $this->assertNotNull($game->cover_path); + } +} diff --git a/tests/Feature/UploadTest.php b/tests/Feature/UploadTest.php index 602e286..d0ddff4 100644 --- a/tests/Feature/UploadTest.php +++ b/tests/Feature/UploadTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature; use App\Features\Consoles\Models\Console; +use App\Features\Games\Models\Game; +use App\Features\Games\Models\RomFile; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -88,4 +90,29 @@ class UploadTest extends TestCase Storage::disk('local')->assertExists("roms/{$console->slug}/game.nes"); } + + public function test_upload_store_skips_duplicate_rom_files(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $console = Console::factory()->create(['rom_extensions' => ['nes']]); + + $content = str_repeat('x', 1024); + $hash = hash('sha256', $content); + + $game = Game::factory()->create(['console_id' => $console->id]); + RomFile::factory()->create([ + 'game_id' => $game->id, + 'file_hash' => $hash, + ]); + + $file = UploadedFile::fake()->createWithContent('game.nes', $content); + + $this->actingAs($user) + ->post(route('upload.store'), [ + 'console_id' => $console->id, + 'rom_files' => [$file], + ]) + ->assertRedirect(); + } } diff --git a/tests/Unit/Data/RomFileDataTest.php b/tests/Unit/Data/RomFileDataTest.php new file mode 100644 index 0000000..50ddffb --- /dev/null +++ b/tests/Unit/Data/RomFileDataTest.php @@ -0,0 +1,69 @@ + 1, + 'game_id' => 1, + 'file_path' => 'roms/nes/game.nes', + 'file_name' => 'game.nes', + 'file_size' => 102400, + 'file_hash' => 'abc123', + 'region' => 'USA', + 'language' => 'en', + ]); + + $this->assertEquals(1, $data->id); + $this->assertEquals(1, $data->gameId); + $this->assertEquals('roms/nes/game.nes', $data->filePath); + $this->assertEquals('game.nes', $data->fileName); + $this->assertEquals(102400, $data->fileSize); + $this->assertEquals('abc123', $data->fileHash); + $this->assertEquals('USA', $data->region); + $this->assertEquals('en', $data->language); + } + + #[Test] + public function it_can_be_created_from_model(): void + { + $romFile = RomFile::factory()->create([ + 'region' => 'Europe', + 'language' => 'de', + ]); + + $data = RomFileData::fromModel($romFile); + + $this->assertEquals($romFile->id, $data->id); + $this->assertEquals($romFile->game_id, $data->gameId); + $this->assertEquals($romFile->file_path, $data->filePath); + $this->assertEquals($romFile->file_name, $data->fileName); + $this->assertEquals($romFile->file_size, $data->fileSize); + $this->assertEquals($romFile->file_hash, $data->fileHash); + $this->assertEquals('Europe', $data->region); + $this->assertEquals('de', $data->language); + } + + #[Test] + public function it_handles_null_optional_fields(): void + { + $romFile = RomFile::factory()->create([ + 'region' => null, + 'language' => null, + ]); + + $data = RomFileData::fromModel($romFile); + + $this->assertNull($data->region); + $this->assertNull($data->language); + } +} diff --git a/tests/Unit/Data/SaveFileDataTest.php b/tests/Unit/Data/SaveFileDataTest.php new file mode 100644 index 0000000..87ad48f --- /dev/null +++ b/tests/Unit/Data/SaveFileDataTest.php @@ -0,0 +1,63 @@ + 1, + 'user_id' => 1, + 'game_id' => 1, + 'file_path' => 'saves/test.srm', + 'file_name' => 'test.srm', + 'file_size' => 1024, + 'last_synced_at' => null, + ]); + + $this->assertEquals(1, $data->id); + $this->assertEquals(1, $data->userId); + $this->assertEquals(1, $data->gameId); + $this->assertEquals('saves/test.srm', $data->filePath); + $this->assertEquals('test.srm', $data->fileName); + $this->assertEquals(1024, $data->fileSize); + $this->assertNull($data->lastSyncedAt); + } + + #[Test] + public function it_can_be_created_from_model(): void + { + $saveFile = SaveFile::factory()->create([ + 'last_synced_at' => '2024-01-15 10:30:00', + ]); + + $data = SaveFileData::fromModel($saveFile); + + $this->assertEquals($saveFile->id, $data->id); + $this->assertEquals($saveFile->user_id, $data->userId); + $this->assertEquals($saveFile->game_id, $data->gameId); + $this->assertEquals($saveFile->file_path, $data->filePath); + $this->assertEquals($saveFile->file_name, $data->fileName); + $this->assertEquals($saveFile->file_size, $data->fileSize); + $this->assertNotNull($data->lastSyncedAt); + } + + #[Test] + public function it_handles_null_last_synced_at(): void + { + $saveFile = SaveFile::factory()->create([ + 'last_synced_at' => null, + ]); + + $data = SaveFileData::fromModel($saveFile); + + $this->assertNull($data->lastSyncedAt); + } +} diff --git a/tests/Unit/Data/ScraperResultDataTest.php b/tests/Unit/Data/ScraperResultDataTest.php new file mode 100644 index 0000000..a1c58ae --- /dev/null +++ b/tests/Unit/Data/ScraperResultDataTest.php @@ -0,0 +1,43 @@ + 123, + 'title' => 'Super Mario Bros', + 'cover_url' => 'https://example.com/cover.jpg', + 'year' => '1985', + 'source' => 'igdb', + ]); + + $this->assertEquals(123, $data->id); + $this->assertEquals('Super Mario Bros', $data->title); + $this->assertEquals('https://example.com/cover.jpg', $data->coverUrl); + $this->assertEquals('1985', $data->year); + $this->assertEquals('igdb', $data->source); + } + + #[Test] + public function it_handles_null_optional_fields(): void + { + $data = ScraperResultData::from([ + 'id' => 1, + 'title' => 'Test Game', + 'cover_url' => null, + 'year' => null, + 'source' => 'thegamesdb', + ]); + + $this->assertNull($data->coverUrl); + $this->assertNull($data->year); + } +} diff --git a/tests/Unit/Data/WebDavAccountDataTest.php b/tests/Unit/Data/WebDavAccountDataTest.php new file mode 100644 index 0000000..134ee3a --- /dev/null +++ b/tests/Unit/Data/WebDavAccountDataTest.php @@ -0,0 +1,42 @@ + 1, + 'user_id' => 1, + 'username' => 'testuser', + 'display_name' => 'Test User', + 'enabled' => true, + ]); + + $this->assertEquals(1, $data->id); + $this->assertEquals(1, $data->userId); + $this->assertEquals('testuser', $data->username); + $this->assertEquals('Test User', $data->displayName); + $this->assertTrue($data->enabled); + } + + #[Test] + public function it_handles_disabled_account(): void + { + $data = WebDavAccountData::from([ + 'id' => 2, + 'user_id' => 1, + 'username' => 'disableduser', + 'display_name' => 'Disabled User', + 'enabled' => false, + ]); + + $this->assertFalse($data->enabled); + } +} diff --git a/tests/Unit/Models/ConsoleTest.php b/tests/Unit/Models/ConsoleTest.php new file mode 100644 index 0000000..dd0cead --- /dev/null +++ b/tests/Unit/Models/ConsoleTest.php @@ -0,0 +1,55 @@ +create(); + Game::factory()->count(3)->create(['console_id' => $console->id]); + + $this->assertCount(3, $console->games); + } + + #[Test] + public function it_uses_slug_as_route_key(): void + { + $console = new Console(); + + $this->assertEquals('slug', $console->getRouteKeyName()); + } + + #[Test] + public function it_casts_rom_extensions_to_array(): void + { + $console = Console::factory()->create(['rom_extensions' => ['nes', 'sms']]); + + $this->assertIsArray($console->rom_extensions); + $this->assertEquals(['nes', 'sms'], $console->rom_extensions); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $console = new Console(); + + $this->assertEquals([ + 'name', + 'slug', + 'icon_path', + 'rom_extensions', + 'igdb_platform_id', + 'tgdb_platform_id', + ], $console->getFillable()); + } +} diff --git a/tests/Unit/Models/GameTest.php b/tests/Unit/Models/GameTest.php new file mode 100644 index 0000000..f2650eb --- /dev/null +++ b/tests/Unit/Models/GameTest.php @@ -0,0 +1,102 @@ +create(); + + $this->assertInstanceOf(Console::class, $game->console); + } + + #[Test] + public function it_has_many_rom_files(): void + { + $game = Game::factory()->create(); + RomFile::factory()->count(3)->create(['game_id' => $game->id]); + + $this->assertCount(3, $game->romFiles); + } + + #[Test] + public function it_has_many_save_files(): void + { + $game = Game::factory()->create(); + SaveFile::factory()->count(2)->create(['game_id' => $game->id]); + + $this->assertCount(2, $game->saveFiles); + } + + #[Test] + public function it_uses_slug_as_route_key(): void + { + $game = new Game(); + + $this->assertEquals('slug', $game->getRouteKeyName()); + } + + #[Test] + public function it_auto_generates_slug_from_title_on_creating(): void + { + $game = Game::factory()->create([ + 'title' => 'Super Mario Bros', + 'slug' => null, + ]); + + $this->assertEquals('super-mario-bros', $game->slug); + } + + #[Test] + public function it_keeps_existing_slug(): void + { + $game = Game::factory()->create([ + 'title' => 'Super Mario Bros', + 'slug' => 'custom-slug', + ]); + + $this->assertEquals('custom-slug', $game->slug); + } + + #[Test] + public function it_casts_rating_to_decimal(): void + { + $game = Game::factory()->create(['rating' => 8.5]); + + $this->assertEquals('8.5', (string) $game->rating); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $game = new Game(); + + $this->assertEquals([ + 'console_id', + 'title', + 'slug', + 'igdb_id', + 'tgdb_id', + 'description', + 'release_year', + 'developer', + 'publisher', + 'genre', + 'rating', + 'cover_path', + 'thumbnail_path', + ], $game->getFillable()); + } +} diff --git a/tests/Unit/Models/RomFileTest.php b/tests/Unit/Models/RomFileTest.php new file mode 100644 index 0000000..29565a7 --- /dev/null +++ b/tests/Unit/Models/RomFileTest.php @@ -0,0 +1,38 @@ +create(); + + $this->assertInstanceOf(Game::class, $romFile->game); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $romFile = new RomFile(); + + $this->assertEquals([ + 'game_id', + 'file_path', + 'file_name', + 'file_size', + 'file_hash', + 'region', + 'language', + ], $romFile->getFillable()); + } +} diff --git a/tests/Unit/Models/SaveFileTest.php b/tests/Unit/Models/SaveFileTest.php new file mode 100644 index 0000000..784d06d --- /dev/null +++ b/tests/Unit/Models/SaveFileTest.php @@ -0,0 +1,56 @@ +create(); + + $this->assertInstanceOf(User::class, $saveFile->user); + } + + #[Test] + public function it_belongs_to_a_game(): void + { + $saveFile = SaveFile::factory()->create(); + + $this->assertInstanceOf(Game::class, $saveFile->game); + } + + #[Test] + public function it_casts_last_synced_at_to_datetime(): void + { + $saveFile = SaveFile::factory()->create([ + 'last_synced_at' => '2024-01-15 10:30:00', + ]); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $saveFile->last_synced_at); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $saveFile = new SaveFile(); + + $this->assertEquals([ + 'user_id', + 'game_id', + 'file_path', + 'file_name', + 'file_size', + 'last_synced_at', + ], $saveFile->getFillable()); + } +} diff --git a/tests/Unit/Models/UserSettingTest.php b/tests/Unit/Models/UserSettingTest.php new file mode 100644 index 0000000..f6f911d --- /dev/null +++ b/tests/Unit/Models/UserSettingTest.php @@ -0,0 +1,34 @@ +create(); + + $this->assertInstanceOf(User::class, $setting->user); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $setting = new UserSetting(); + + $this->assertEquals([ + 'user_id', + 'setting_key', + 'setting_value', + ], $setting->getFillable()); + } +} diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php new file mode 100644 index 0000000..35143e5 --- /dev/null +++ b/tests/Unit/Models/UserTest.php @@ -0,0 +1,129 @@ +create(); + SaveFile::factory()->count(3)->create(['user_id' => $user->id]); + + $this->assertCount(3, $user->saveFiles); + } + + #[Test] + public function it_has_many_settings(): void + { + $user = User::factory()->create(); + UserSetting::factory()->count(2)->create(['user_id' => $user->id]); + + $this->assertCount(2, $user->settings); + } + + #[Test] + public function it_gets_setting_by_key(): void + { + $user = User::factory()->create(); + UserSetting::factory()->create([ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + + $this->assertEquals('dark', $user->getSetting('theme')); + } + + #[Test] + public function it_returns_default_when_setting_not_found(): void + { + $user = User::factory()->create(); + + $this->assertEquals('default', $user->getSetting('nonexistent', 'default')); + } + + #[Test] + public function it_returns_null_when_setting_not_found_without_default(): void + { + $user = User::factory()->create(); + + $this->assertNull($user->getSetting('nonexistent')); + } + + #[Test] + public function it_sets_setting(): void + { + $user = User::factory()->create(); + + $user->setSetting('theme', 'dark'); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + } + + #[Test] + public function it_updates_existing_setting(): void + { + $user = User::factory()->create(); + UserSetting::factory()->create([ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'light', + ]); + + $user->setSetting('theme', 'dark'); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + $this->assertDatabaseCount('user_settings', 1); + } + + #[Test] + public function it_casts_email_verified_at_to_datetime(): void + { + $user = User::factory()->create(['email_verified_at' => '2024-01-15 10:30:00']); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $user->email_verified_at); + } + + #[Test] + public function it_hashes_password(): void + { + $user = User::factory()->create(['password' => 'test-password']); + + $this->assertNotEquals('test-password', $user->password); + $this->assertTrue(\Hash::check('test-password', $user->password)); + } + + #[Test] + public function it_has_fillable_attributes(): void + { + $user = new User(); + + $this->assertEquals(['name', 'email', 'password'], $user->getFillable()); + } + + #[Test] + public function it_has_hidden_attributes(): void + { + $user = new User(); + + $this->assertEquals(['password', 'remember_token'], $user->getHidden()); + } +} diff --git a/tests/Unit/Providers/HorizonServiceProviderTest.php b/tests/Unit/Providers/HorizonServiceProviderTest.php new file mode 100644 index 0000000..4826cd9 --- /dev/null +++ b/tests/Unit/Providers/HorizonServiceProviderTest.php @@ -0,0 +1,28 @@ +assertTrue(Gate::has('viewHorizon')); + } + + #[Test] + public function it_denies_access_for_users_not_in_list(): void + { + $user = User::factory()->create(); + + $this->assertFalse(Gate::forUser($user)->allows('viewHorizon')); + } +} diff --git a/tests/Unit/Providers/TypeScriptTransformerServiceProviderTest.php b/tests/Unit/Providers/TypeScriptTransformerServiceProviderTest.php new file mode 100644 index 0000000..78d27d5 --- /dev/null +++ b/tests/Unit/Providers/TypeScriptTransformerServiceProviderTest.php @@ -0,0 +1,51 @@ +app); + + $this->assertInstanceOf(BaseTypeScriptTransformerServiceProvider::class, $provider); + } + + #[Test] + public function it_configures_transformers(): void + { + $config = $this->createMock(TypeScriptTransformerConfigFactory::class); + + $config->expects($this->exactly(3)) + ->method('transformer') + ->willReturnSelf(); + + $config->expects($this->once()) + ->method('transformDirectories') + ->willReturnSelf(); + + $config->expects($this->once()) + ->method('writer') + ->willReturnSelf(); + + $config->expects($this->once()) + ->method('formatter') + ->willReturnSelf(); + + $provider = new TypeScriptTransformerServiceProvider($this->app); + + $reflection = new \ReflectionClass($provider); + $method = $reflection->getMethod('configure'); + $method->setAccessible(true); + + $method->invoke($provider, $config); + } +} diff --git a/tests/Unit/Repositories/ConsoleRepositoryTest.php b/tests/Unit/Repositories/ConsoleRepositoryTest.php new file mode 100644 index 0000000..a48c834 --- /dev/null +++ b/tests/Unit/Repositories/ConsoleRepositoryTest.php @@ -0,0 +1,64 @@ +repository = new ConsoleRepository(new Console()); + } + + #[Test] + public function it_gets_all_consoles_with_games_count(): void + { + Console::factory()->count(3)->create(); + + $consoles = $this->repository->getAll(); + + $this->assertCount(3, $consoles); + } + + #[Test] + public function it_gets_console_by_slug(): void + { + $console = Console::factory()->create(['slug' => 'nes']); + + $found = $this->repository->getBySlug('nes'); + + $this->assertNotNull($found); + $this->assertEquals($console->id, $found->id); + } + + #[Test] + public function it_returns_null_for_nonexistent_slug(): void + { + $found = $this->repository->getBySlug('nonexistent'); + + $this->assertNull($found); + } + + #[Test] + public function it_gets_consoles_with_games_count(): void + { + $console = Console::factory()->create(); + Game::factory()->count(5)->create(['console_id' => $console->id]); + + $consoles = $this->repository->getWithGamesCount(); + + $this->assertCount(1, $consoles); + $this->assertEquals(5, $consoles->first()->games_count); + } +} diff --git a/tests/Unit/Repositories/GameRepositoryTest.php b/tests/Unit/Repositories/GameRepositoryTest.php new file mode 100644 index 0000000..decb67c --- /dev/null +++ b/tests/Unit/Repositories/GameRepositoryTest.php @@ -0,0 +1,126 @@ +repository = new GameRepository(new Game()); + } + + #[Test] + public function it_gets_games_by_console(): void + { + $console = Console::factory()->create(); + Game::factory()->count(5)->create(['console_id' => $console->id]); + Game::factory()->count(3)->create(); + + $games = $this->repository->getByConsole($console->id); + + $this->assertCount(5, $games); + } + + #[Test] + public function it_gets_game_by_slug(): void + { + $game = Game::factory()->create(['slug' => 'super-mario']); + + $found = $this->repository->getBySlug('super-mario'); + + $this->assertNotNull($found); + $this->assertEquals($game->id, $found->id); + } + + #[Test] + public function it_returns_null_for_nonexistent_slug(): void + { + $found = $this->repository->getBySlug('nonexistent'); + + $this->assertNull($found); + } + + #[Test] + public function it_gets_recent_games(): void + { + Game::factory()->count(5)->create(); + + $recent = $this->repository->getRecent(3); + + $this->assertCount(3, $recent); + } + + #[Test] + public function it_searches_games_by_title(): void + { + Game::factory()->create(['title' => 'Super Mario Bros']); + Game::factory()->create(['title' => 'Super Mario World']); + Game::factory()->create(['title' => 'Sonic the Hedgehog']); + + $results = $this->repository->search('Mario'); + + $this->assertCount(2, $results); + } + + #[Test] + public function it_gets_all_games_paginated(): void + { + Game::factory()->count(30)->create(); + + $all = $this->repository->getAll(); + + $this->assertCount(24, $all); + $this->assertEquals(30, $all->total()); + } + + #[Test] + public function it_creates_a_game(): void + { + $game = $this->repository->create([ + 'console_id' => Console::factory()->create()->id, + 'title' => 'New Game', + 'slug' => 'new-game', + ]); + + $this->assertDatabaseHas('games', ['slug' => 'new-game']); + } + + #[Test] + public function it_creates_game_first_or_creates(): void + { + $console = Console::factory()->create(); + + $game1 = $this->repository->firstOrCreate( + ['console_id' => $console->id, 'slug' => 'test-game'], + ['title' => 'Test Game'] + ); + + $game2 = $this->repository->firstOrCreate( + ['console_id' => $console->id, 'slug' => 'test-game'], + ['title' => 'Different Title'] + ); + + $this->assertEquals($game1->id, $game2->id); + $this->assertEquals('Test Game', $game2->title); + } + + #[Test] + public function it_counts_games(): void + { + Game::factory()->count(5)->create(); + + $this->assertEquals(5, $this->repository->count()); + } +} diff --git a/tests/Unit/Repositories/RomFileRepositoryTest.php b/tests/Unit/Repositories/RomFileRepositoryTest.php new file mode 100644 index 0000000..bf0a42a --- /dev/null +++ b/tests/Unit/Repositories/RomFileRepositoryTest.php @@ -0,0 +1,90 @@ +repository = new RomFileRepository(new RomFile()); + } + + #[Test] + public function it_counts_rom_files(): void + { + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + RomFile::factory()->count(5)->create(['game_id' => $game->id]); + + $this->assertEquals(5, $this->repository->count()); + } + + #[Test] + public function it_finds_by_hash(): void + { + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + $romFile = RomFile::factory()->create([ + 'game_id' => $game->id, + 'file_hash' => 'abc123', + ]); + + $found = $this->repository->findByHash('abc123'); + + $this->assertNotNull($found); + $this->assertEquals($romFile->id, $found->id); + } + + #[Test] + public function it_returns_null_for_nonexistent_hash(): void + { + $found = $this->repository->findByHash('nonexistent'); + + $this->assertNull($found); + } + + #[Test] + public function it_creates_a_rom_file(): void + { + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + + $romFile = $this->repository->create([ + 'game_id' => $game->id, + 'file_path' => 'roms/nes/game.nes', + 'file_name' => 'game.nes', + 'file_size' => 102400, + 'file_hash' => 'abc123', + ]); + + $this->assertDatabaseHas('rom_files', ['file_hash' => 'abc123']); + } + + #[Test] + public function it_gets_rom_files_by_game(): void + { + $console = Console::factory()->create(); + $game = Game::factory()->create(['console_id' => $console->id]); + RomFile::factory()->count(3)->create(['game_id' => $game->id]); + + $otherGame = Game::factory()->create(['console_id' => $console->id]); + RomFile::factory()->count(2)->create(['game_id' => $otherGame->id]); + + $romFiles = $this->repository->getByGame($game->id); + + $this->assertCount(3, $romFiles); + } +} diff --git a/tests/Unit/Repositories/SettingsRepositoryTest.php b/tests/Unit/Repositories/SettingsRepositoryTest.php new file mode 100644 index 0000000..416a6dd --- /dev/null +++ b/tests/Unit/Repositories/SettingsRepositoryTest.php @@ -0,0 +1,120 @@ +repository = new SettingsRepository( + new SaveFile(), + new UserSetting() + ); + } + + #[Test] + public function it_gets_user_saves(): void + { + $user = User::factory()->create(); + SaveFile::factory()->count(3)->create(['user_id' => $user->id]); + SaveFile::factory()->count(2)->create(); + + $saves = $this->repository->getUserSaves($user); + + $this->assertCount(3, $saves); + } + + #[Test] + public function it_gets_user_settings(): void + { + $user = User::factory()->create(); + UserSetting::factory()->count(2)->create(['user_id' => $user->id]); + UserSetting::factory()->count(3)->create(); + + $settings = $this->repository->getUserSettings($user); + + $this->assertCount(2, $settings); + } + + #[Test] + public function it_gets_a_setting_by_key(): void + { + $user = User::factory()->create(); + UserSetting::factory()->create([ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + + $value = $this->repository->getSetting($user, 'theme'); + + $this->assertEquals('dark', $value); + } + + #[Test] + public function it_returns_default_when_setting_not_found(): void + { + $user = User::factory()->create(); + + $value = $this->repository->getSetting($user, 'nonexistent', 'default_value'); + + $this->assertEquals('default_value', $value); + } + + #[Test] + public function it_returns_null_when_setting_not_found_without_default(): void + { + $user = User::factory()->create(); + + $value = $this->repository->getSetting($user, 'nonexistent'); + + $this->assertNull($value); + } + + #[Test] + public function it_creates_a_setting(): void + { + $user = User::factory()->create(); + + $this->repository->setSetting($user, 'theme', 'dark'); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + } + + #[Test] + public function it_updates_an_existing_setting(): void + { + $user = User::factory()->create(); + UserSetting::factory()->create([ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'light', + ]); + + $this->repository->setSetting($user, 'theme', 'dark'); + + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'setting_key' => 'theme', + 'setting_value' => 'dark', + ]); + $this->assertDatabaseCount('user_settings', 1); + } +} diff --git a/tests/Unit/Services/IGDBCoverScraperTest.php b/tests/Unit/Services/IGDBCoverScraperTest.php new file mode 100644 index 0000000..b454fa3 --- /dev/null +++ b/tests/Unit/Services/IGDBCoverScraperTest.php @@ -0,0 +1,256 @@ + '', 'services.igdb.client_secret' => '']); + + $scraper = new IGDBCoverScraper(); + + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_returns_null_cover_when_no_config(): void + { + config(['services.igdb.client_id' => '', 'services.igdb.client_secret' => '']); + + $scraper = new IGDBCoverScraper(); + + $this->assertNull($scraper->getCoverUrl(123)); + } + + #[Test] + public function it_returns_null_screenshot_when_no_config(): void + { + config(['services.igdb.client_id' => '', 'services.igdb.client_secret' => '']); + + $scraper = new IGDBCoverScraper(); + + $this->assertNull($scraper->getScreenshotUrl(123)); + } + + #[Test] + public function it_returns_empty_images_when_no_config(): void + { + config(['services.igdb.client_id' => '', 'services.igdb.client_secret' => '']); + + $scraper = new IGDBCoverScraper(); + + $images = $scraper->getImages(123); + + $this->assertEmpty($images); + } + + #[Test] + public function it_searches_games_with_valid_config(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/games' => Http::response([ + [ + 'id' => 1, + 'name' => 'Super Mario Bros', + 'cover' => ['url' => 'https://images.igdb.com/igdb/image/upload/thumb/test.jpg'], + 'release_dates' => [['year' => 1985]], + ], + ]), + ]); + + $scraper = new IGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertCount(1, $results); + $this->assertEquals('Super Mario Bros', $results->first()['title']); + $this->assertEquals('igdb', $results->first()['source']); + } + + #[Test] + public function it_searches_games_with_console_id(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/games' => Http::response([ + [ + 'id' => 1, + 'name' => 'Super Mario Bros', + 'cover' => null, + 'release_dates' => [], + ], + ]), + ]); + + $scraper = new IGDBCoverScraper(); + $results = $scraper->search('mario', 21); + + $this->assertCount(1, $results); + $this->assertNull($results->first()['cover_url']); + $this->assertNull($results->first()['year']); + } + + #[Test] + public function it_returns_empty_on_api_failure(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/games' => Http::response([], 500), + ]); + + $scraper = new IGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_returns_empty_on_exception(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/games' => fn () => throw new \Exception('Network error'), + ]); + + $scraper = new IGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_gets_cover_url(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/covers' => Http::response([ + ['url' => 'https://images.igdb.com/igdb/image/upload/thumb/test.jpg'], + ]), + ]); + + $scraper = new IGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNotNull($url); + $this->assertStringContainsString('cover_big', $url); + } + + #[Test] + public function it_returns_null_cover_when_empty_response(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/covers' => Http::response([]), + ]); + + $scraper = new IGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_gets_screenshot_url(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/screenshots' => Http::response([ + ['url' => 'https://images.igdb.com/igdb/image/upload/thumb/screenshot.jpg'], + ]), + ]); + + $scraper = new IGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNotNull($url); + } + + #[Test] + public function it_returns_null_screenshot_when_empty(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/screenshots' => Http::response([]), + ]); + + $scraper = new IGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_gets_images(): void + { + config([ + 'services.igdb.client_id' => 'test-id', + 'services.igdb.client_secret' => 'test-secret', + ]); + + Http::fake([ + 'https://id.twitch.tv/oauth2/token' => Http::response(['access_token' => 'fake-token']), + 'https://api.igdb.com/v4/covers' => Http::response([ + ['url' => 'https://images.igdb.com/igdb/image/upload/thumb/test.jpg'], + ]), + 'https://api.igdb.com/v4/screenshots' => Http::response([ + ['url' => 'https://images.igdb.com/igdb/image/upload/thumb/screenshot.jpg'], + ]), + ]); + + $scraper = new IGDBCoverScraper(); + $images = $scraper->getImages(123); + + $this->assertCount(2, $images); + $this->assertArrayHasKey('cover', $images); + $this->assertArrayHasKey('screenshot', $images); + } +} diff --git a/tests/Unit/Services/ScraperServiceTest.php b/tests/Unit/Services/ScraperServiceTest.php new file mode 100644 index 0000000..d4811ef --- /dev/null +++ b/tests/Unit/Services/ScraperServiceTest.php @@ -0,0 +1,201 @@ +igdb = $this->createMock(IGDBCoverScraper::class); + $this->tgdb = $this->createMock(TGDBCoverScraper::class); + $this->service = new ScraperService($this->igdb, $this->tgdb); + } + + #[Test] + public function it_searches_with_igdb_first(): void + { + $console = Console::factory()->create(['igdb_platform_id' => 21]); + $expected = collect([['title' => 'Game']]); + + $this->igdb->expects($this->once()) + ->method('search') + ->with('mario', 21) + ->willReturn($expected); + + $results = $this->service->search('mario', $console->slug); + + $this->assertEquals($expected, $results); + } + + #[Test] + public function it_falls_back_to_tgdb_when_igdb_returns_empty(): void + { + $console = Console::factory()->create([ + 'igdb_platform_id' => 21, + 'tgdb_platform_id' => 42, + ]); + $expected = collect([['title' => 'Game']]); + + $this->igdb->expects($this->once()) + ->method('search') + ->willReturn(collect()); + + $this->tgdb->expects($this->once()) + ->method('search') + ->with('mario', 42) + ->willReturn($expected); + + $results = $this->service->search('mario', $console->slug); + + $this->assertEquals($expected, $results); + } + + #[Test] + public function it_searches_without_console(): void + { + $expected = collect([['title' => 'Game']]); + + $this->igdb->expects($this->once()) + ->method('search') + ->with('mario', null) + ->willReturn($expected); + + $results = $this->service->search('mario'); + + $this->assertEquals($expected, $results); + } + + #[Test] + public function it_scrapes_cover_for_game_with_igdb_id(): void + { + $game = Game::factory()->create(['igdb_id' => 123, 'tgdb_id' => null]); + $game->load('console'); + + $this->igdb->expects($this->once()) + ->method('getCoverUrl') + ->with(123) + ->willReturn('https://example.com/cover.jpg'); + + $this->tgdb->expects($this->never()) + ->method('getCoverUrl'); + + Http::fake([ + 'https://example.com/cover.jpg' => Http::response('image-data'), + ]); + Storage::fake('local'); + + $result = $this->service->scrapeForGame($game); + + $this->assertNotNull($result); + } + + #[Test] + public function it_falls_back_to_tgdb_for_cover(): void + { + $game = Game::factory()->create(['igdb_id' => 123, 'tgdb_id' => 456]); + $game->load('console'); + + $this->igdb->expects($this->once()) + ->method('getCoverUrl') + ->with(123) + ->willReturn(null); + + $this->tgdb->expects($this->once()) + ->method('getCoverUrl') + ->with(456) + ->willReturn('https://example.com/tgdb-cover.jpg'); + + Http::fake([ + 'https://example.com/tgdb-cover.jpg' => Http::response('image-data'), + ]); + Storage::fake('local'); + + $result = $this->service->scrapeForGame($game); + + $this->assertNotNull($result); + } + + #[Test] + public function it_returns_null_when_no_cover_found(): void + { + $game = Game::factory()->create(['igdb_id' => 123, 'tgdb_id' => null]); + $game->load('console'); + + $this->igdb->expects($this->once()) + ->method('getCoverUrl') + ->willReturn(null); + + $this->tgdb->expects($this->never()) + ->method('getCoverUrl'); + + $result = $this->service->scrapeForGame($game); + + $this->assertNull($result); + } + + #[Test] + public function it_downloads_cover(): void + { + $game = Game::factory()->create(['slug' => 'super-mario']); + $game->setRelation('console', Console::factory()->create(['slug' => 'nes'])); + + Http::fake([ + 'https://example.com/cover.jpg' => Http::response('image-data'), + ]); + Storage::fake('local'); + + $path = $this->service->downloadCover('https://example.com/cover.jpg', $game); + + $this->assertEquals('covers/nes/super-mario-cover.jpg', $path); + Storage::disk('local')->assertExists('covers/nes/super-mario-cover.jpg'); + } + + #[Test] + public function it_returns_empty_string_on_download_failure(): void + { + $game = Game::factory()->create(['slug' => 'super-mario']); + $game->setRelation('console', Console::factory()->create(['slug' => 'nes'])); + + Http::fake([ + 'https://example.com/cover.jpg' => fn () => throw new \Exception('Network error'), + ]); + Storage::fake('local'); + + $path = $this->service->downloadCover('https://example.com/cover.jpg', $game); + + $this->assertEquals('', $path); + } + + #[Test] + public function it_downloads_cover_with_unknown_console(): void + { + $game = Game::factory()->create(['slug' => 'test-game']); + $game->setRelation('console', null); + + Http::fake([ + 'https://example.com/cover.jpg' => Http::response('image-data'), + ]); + Storage::fake('local'); + + $path = $this->service->downloadCover('https://example.com/cover.jpg', $game); + + $this->assertEquals('covers/unknown/test-game-cover.jpg', $path); + } +} diff --git a/tests/Unit/Services/TGDBCoverScraperTest.php b/tests/Unit/Services/TGDBCoverScraperTest.php new file mode 100644 index 0000000..1f9719a --- /dev/null +++ b/tests/Unit/Services/TGDBCoverScraperTest.php @@ -0,0 +1,319 @@ + '']); + + $scraper = new TGDBCoverScraper(); + + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_returns_null_cover_when_no_config(): void + { + config(['services.thegamesdb.api_key' => '']); + + $scraper = new TGDBCoverScraper(); + + $this->assertNull($scraper->getCoverUrl(123)); + } + + #[Test] + public function it_returns_null_screenshot_when_no_config(): void + { + config(['services.thegamesdb.api_key' => '']); + + $scraper = new TGDBCoverScraper(); + + $this->assertNull($scraper->getScreenshotUrl(123)); + } + + #[Test] + public function it_returns_empty_images_when_no_config(): void + { + config(['services.thegamesdb.api_key' => '']); + + $scraper = new TGDBCoverScraper(); + + $images = $scraper->getImages(123); + + $this->assertEmpty($images); + } + + #[Test] + public function it_searches_games(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/ByGameName*' => Http::response([ + 'data' => [ + 'games' => [ + [ + 'id' => 1, + 'game_title' => 'Super Mario Bros', + 'cover' => 'https://example.com/cover.jpg', + 'release_date' => '1985-09-13', + ], + ], + ], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertCount(1, $results); + $this->assertEquals('Super Mario Bros', $results->first()['title']); + $this->assertEquals('thegamesdb', $results->first()['source']); + $this->assertEquals('1985', $results->first()['year']); + } + + #[Test] + public function it_searches_games_with_console_id(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/ByGameName*' => Http::response([ + 'data' => ['games' => []], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $results = $scraper->search('mario', 21); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_searches_games_without_release_date(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/ByGameName*' => Http::response([ + 'data' => [ + 'games' => [ + [ + 'id' => 1, + 'game_title' => 'Test Game', + 'cover' => null, + ], + ], + ], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $results = $scraper->search('test'); + + $this->assertCount(1, $results); + $this->assertNull($results->first()['year']); + $this->assertNull($results->first()['cover_url']); + } + + #[Test] + public function it_returns_empty_on_api_failure(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/ByGameName*' => Http::response([], 500), + ]); + + $scraper = new TGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_returns_empty_on_exception(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/ByGameName*' => fn () => throw new \Exception('Network error'), + ]); + + $scraper = new TGDBCoverScraper(); + $results = $scraper->search('mario'); + + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_gets_cover_url(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([ + 'data' => [ + 'images' => [ + ['filename' => 'cover.jpg'], + ], + ], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNotNull($url); + $this->assertStringContainsString('cover.jpg', $url); + } + + #[Test] + public function it_returns_null_cover_when_empty(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([ + 'data' => ['images' => []], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_returns_null_cover_on_api_failure(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([], 500), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_returns_null_cover_on_exception(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => fn () => throw new \Exception('Network error'), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getCoverUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_gets_screenshot_url(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([ + 'data' => [ + 'images' => [ + ['filename' => 'screenshot.jpg'], + ], + ], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNotNull($url); + $this->assertStringContainsString('screenshot.jpg', $url); + } + + #[Test] + public function it_returns_null_screenshot_when_empty(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([ + 'data' => ['images' => []], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_returns_null_screenshot_on_api_failure(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([], 500), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_returns_null_screenshot_on_exception(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => fn () => throw new \Exception('Network error'), + ]); + + $scraper = new TGDBCoverScraper(); + $url = $scraper->getScreenshotUrl(123); + + $this->assertNull($url); + } + + #[Test] + public function it_gets_images(): void + { + config(['services.thegamesdb.api_key' => 'test-key']); + + Http::fake([ + '*/Games/Images*' => Http::response([ + 'data' => [ + 'images' => [ + ['filename' => 'boxart.jpg'], + ], + ], + ]), + ]); + + $scraper = new TGDBCoverScraper(); + $images = $scraper->getImages(123); + + $this->assertCount(2, $images); + $this->assertArrayHasKey('cover', $images); + $this->assertArrayHasKey('screenshot', $images); + } +}