diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..44f4bbe --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,20 @@ +name: CI - Build & Test (PushAndPull) +on: + pull_request: + branches: ["*"] +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET 9 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore PushAndPull/PushAndPull.sln + - name: Build solution + run: dotnet build PushAndPull/PushAndPull.sln --configuration Release + - name: Run all tests + run: dotnet test PushAndPull/PushAndPull.sln --no-build --configuration Release --logger "trx;LogFileName=test_results.trx" diff --git a/PushAndPull/.claude/commands/build.md b/PushAndPull/.claude/commands/build.md new file mode 100644 index 0000000..3f810ef --- /dev/null +++ b/PushAndPull/.claude/commands/build.md @@ -0,0 +1,16 @@ +--- +description: Build the server project and report errors +allowed-tools: Bash +--- + +Build the server project and report any errors. + +## Steps + +1. Run the build command: + ```bash + dotnet build Server/Server.csproj + ``` +2. Check the build output: + - If the build **succeeds**: confirm success and show the summary (warnings, if any) + - If the build **fails**: list each error with its file path and line number, then explain the likely cause and how to fix it diff --git a/PushAndPull/.claude/commands/commit.md b/PushAndPull/.claude/commands/commit.md new file mode 100644 index 0000000..203f549 --- /dev/null +++ b/PushAndPull/.claude/commands/commit.md @@ -0,0 +1,49 @@ +--- +description: Create Git commits by splitting changes into logical units +allowed-tools: Bash +--- + +Create Git commits following the project's commit conventions. + +## Commit Message Format + +``` +{type}: {Korean description} +``` + +**Types**: +- `feat` — new feature added +- `fix` — bug fix, missing config, or missing DI registration +- `modify` — modification to existing code + +**Description rules**: +- Written in **Korean** +- Short and imperative (단문) +- No trailing punctuation (`.`, `!` etc.) +- Avoid noun-ending style — prefer verb style + +**Examples**: +``` +feat: 방 생성 API 추가 +fix: 세션 DI 누락 수정 +modify: Room 엔터티 수정 +``` + +**Do NOT**: +- Add Claude as co-author +- Write descriptions in English +- Add a commit body — subject line only + +## Steps + +1. Check all changes with `git status` and `git diff` +2. Categorize changes into logical units: + - New feature addition → `feat` + - Bug / missing registration fix → `fix` + - Modification to existing code → `modify` +3. Group files by each logical unit +4. For each group: + - Stage only the relevant files with `git add ` + - Write a concise commit message following the format above + - Execute `git commit -m "message"` +5. Verify results with `git log --oneline -n {number of commits made}` diff --git a/PushAndPull/.claude/commands/migrate.md b/PushAndPull/.claude/commands/migrate.md new file mode 100644 index 0000000..693fed4 --- /dev/null +++ b/PushAndPull/.claude/commands/migrate.md @@ -0,0 +1,24 @@ +--- +description: Add an EF Core migration and optionally update the database +allowed-tools: Bash, Read +--- + +Add an EF Core migration using the name provided as the argument. + +Usage: `/migrate ` + +## Steps + +1. Run the migration command using the argument as the migration name: + ```bash + dotnet ef migrations add $ARGUMENTS --project Server + ``` +2. After the migration is created: + - Read the generated migration file under `Server/Migrations/` + - Briefly summarize what the migration does (tables created/altered, columns added/removed, indexes, etc.) +3. Ask the user whether to apply the migration to the database: + - If yes, run: + ```bash + dotnet ef database update --project Server + ``` + - Confirm the update result when done diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/commands/pr.md new file mode 100644 index 0000000..20f7d05 --- /dev/null +++ b/PushAndPull/.claude/commands/pr.md @@ -0,0 +1,191 @@ +--- +description: Generate PR title suggestions and body based on changes from develop +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion +--- + +Generate a PR based on the current branch. Behavior differs depending on the branch. + +## Context + +- Current branch: !`git branch --show-current` + +--- + +## Branch-Based Behavior + +### Case 1: Current branch is `develop` + +**Step 1. Check the current version** + +- Check git tags: `git tag --sort=-v:refname | head -10` +- Check existing release branches: `git branch -a | grep release` +- Determine the latest version (e.g., `1.0.0`) + +**Step 2. Analyze changes and recommend version bump** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` +- Recommend one of: + - **Major** (x.0.0): Breaking changes, incompatible API changes + - **Minor** (0.x.0): New backward-compatible features + - **Patch** (0.0.x): Bug fixes only +- Briefly explain why you chose that level + +**Step 3. Ask user for version number** + +Use AskUserQuestion: +> "현재 버전: {current_version} +> 추천 버전 업: {Major/Minor/Patch} → {recommended_version} +> 이유: {brief reason} +> +> 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" + +**Step 4. Create release branch** + +```bash +git checkout -b release/{version} +``` + +**Step 5. Write PR body** following the PR Body Template below +- Analyze changes from `main` branch +- Save to `PR_BODY.md` + +**Step 6. Create PR to `main`** + +```bash +gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +``` + +**Step 7. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 2: Current branch is `release/x.x.x` + +**Step 1. Extract version** from branch name (e.g., `release/1.2.0` → `1.2.0`) + +**Step 2. Analyze changes from `main`** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` + +**Step 3. Write PR body** following the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Create PR to `main`** + +```bash +gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +``` + +**Step 5. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 3: Any other branch + +**Step 1. Analyze changes from `develop`** + +- Commits: `git log develop..HEAD --oneline` +- Diff stats: `git diff develop...HEAD --stat` +- Detailed diff: `git diff develop...HEAD` + +**Step 2. Suggest 3 PR titles** following the PR Title Convention below + +**Step 3. Write PR body** following the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Output** in this format: +``` +## 추천 PR 제목 + +1. [title1] +2. [title2] +3. [title3] + +## PR 본문 (PR_BODY.md에 저장됨) + +[full body preview] +``` + +**Step 5. Ask the user** using AskUserQuestion: +> "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" + +**Step 6. Create PR to `develop`** + +- If the user answered 1, 2, or 3, use the corresponding suggested title +- If the user typed a custom title, use it as-is + +```bash +gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop +``` + +**Step 7. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +## PR Title Convention + +Format: `{type}: {Korean description}` + +**Types:** +- `feature` — new feature added +- `fix` — bug fix or missing configuration/DI registration +- `modify` — modification to existing code +- `refactor` — refactoring without behavior change + +**Rules:** +- Description in Korean +- Short and imperative (단문) +- No trailing punctuation + +**Examples:** +- `feature: 방 생성 API 추가` +- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` +- `refactor: 로그인 로직 리팩토링` + +--- + +## PR Body Template + +Follow this exact structure (keep the emoji headers as-is): + +``` +## 📚작업 내용 + +- {change item 1} +- {change item 2} + +## ◀️참고 사항 + +{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* +``` + +**Rules:** +- Analyze commits and diffs to fill in `작업 내용` with a concise bullet list +- Fill in `참고 사항` with any important context (architecture decisions, before/after, caveats). Write `.` if nothing relevant. +- Keep total body under 2500 characters +- Write in Korean +- No emojis in text content (keep the section header emojis) diff --git a/PushAndPull/.claude/commands/test.md b/PushAndPull/.claude/commands/test.md new file mode 100644 index 0000000..c0cb798 --- /dev/null +++ b/PushAndPull/.claude/commands/test.md @@ -0,0 +1,16 @@ +--- +description: Run all tests and analyze failures +allowed-tools: Bash +--- + +Run the full test suite and report results. + +## Steps + +1. Run all tests: + ```bash + dotnet test PushAndPull.sln --logger "console;verbosity=normal" + ``` +2. Check the test output: + - If all tests **pass**: confirm success and show the summary (total tests, duration) + - If any tests **fail**: list each failing test by name, show the failure message and stack trace, then explain the likely cause and how to fix it diff --git a/PushAndPull/.claude/settings.json b/PushAndPull/.claude/settings.json new file mode 100644 index 0000000..deffac9 --- /dev/null +++ b/PushAndPull/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "hooks": {} +} diff --git a/PushAndPull/.gitignore b/PushAndPull/.gitignore index b4e9c7c..431aaac 100644 --- a/PushAndPull/.gitignore +++ b/PushAndPull/.gitignore @@ -1,4 +1,7 @@ -# .NET build output +# Claude Code +PR_BODY.md + +# .NET build output bin/ obj/ @@ -7,119 +10,11 @@ project.assets.json project.nuget.cache *.nuget.* -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml - -# Rider에서 무시된 파일 -/projectSettingsUpdater.xml -/.idea.PushAndPull.iml -/modules.xml -/contentModel.xml - -# 쿼리 파일을 포함한 무시된 디폴트 폴더 -/queries/ - -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml - -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ - -# Rider 사용자별 설정 -/dictionaries/ -*.sw? -.idea_modules/ - -# 작업 및 통계 -/tasks.xml -/usage.statistics.xml -/statistics.xml - -# 빌드 및 컴파일 캐시 -/caches/ -/compiler.xml -/encodings.xml -/misc.xml -/vcs.xml - -# 사용자 라이브러리 -/libraries/ - -# 실행 구성 (선택사항) -# 팀원과 공유하고 싶다면 주석처리 -/runConfigurations/ - -# 로컬 히스토리 -/localHistory/ - -# 인덱싱 -/indexLayout.xml - -# 동적 생성 파일 -/dynamic.xml - -# 플러그인 -/externalDependencies.xml -/jarRepositories.xml - -# 디버깅 -/debugger.xml - -# 코드 스타일 (팀원과 공유하고 싶다면 주석처리) -# /codeStyles/ - -# 검사 프로필 (팀원과 공유하고 싶다면 주석처리) -# /inspectionProfiles/ - -# 사용자별 UI 설정 -/uiDesigner.xml -/dbnavigator.xml - -# Git 도구창 -/git_toolbox_prj.xml - -# 파일 색상 -/fileColors.xml - -# 스크래치 파일 -/scratches/ - -# 로컬 DB 스키마 -/dbSchemas/ - -# SQL 다이얼렉트 -/sqldialects.xml - -# 프로젝트 레벨 사전 -/dictionaries/ - -# TODO 설정 -/aws.xml - -# 파일 템플릿 -/fileTemplates/ - -# 프로젝트 뷰 -/projectView.xml - -# 스코프 -/scopes/ - -# Rider 특정 설정 -/.idea.*.dir/ +# JetBrains IDE (Rider / IntelliJ) +.idea/ *.iws -/sonarlint/ - -# Avalonia 관련 (사용 시) -/avalonia.xml - -# 사용자 사전 -/shelf.xml - -# NuGet 캐시 (Rider) -/.idea.*/ +*.iml +*.ipr -# 파일 인코딩 -/fileEncodings.xml \ No newline at end of file +# Visual Studio user settings +*.DotSettings.user \ No newline at end of file diff --git a/PushAndPull/.idea/.idea.PushAndPull/.idea/.gitignore b/PushAndPull/.idea/.idea.PushAndPull/.idea/.gitignore deleted file mode 100644 index fea8c33..0000000 --- a/PushAndPull/.idea/.idea.PushAndPull/.idea/.gitignore +++ /dev/null @@ -1,120 +0,0 @@ -# ========================================== -# .idea/.gitignore (Rider 전용) -# ========================================== - -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml - -# Rider에서 무시된 파일 -/projectSettingsUpdater.xml -/.idea.PushAndPull.iml -/modules.xml -/contentModel.xml - -# 쿼리 파일을 포함한 무시된 디폴트 폴더 -/queries/ - -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml - -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ - -# Rider 사용자별 설정 -/dictionaries/ -*.sw? -.idea_modules/ - -# 작업 및 통계 -/tasks.xml -/usage.statistics.xml -/statistics.xml - -# 빌드 및 컴파일 캐시 -/caches/ -/compiler.xml -/encodings.xml -/misc.xml -/vcs.xml - -# 사용자 라이브러리 -/libraries/ - -# 실행 구성 (선택사항) -# 팀원과 공유하고 싶다면 주석처리 -/runConfigurations/ - -# 로컬 히스토리 -/localHistory/ - -# 인덱싱 -/indexLayout.xml - -# 동적 생성 파일 -/dynamic.xml - -# 플러그인 -/externalDependencies.xml -/jarRepositories.xml - -# 디버깅 -/debugger.xml - -# 코드 스타일 (팀원과 공유하고 싶다면 주석처리) -# /codeStyles/ - -# 검사 프로필 (팀원과 공유하고 싶다면 주석처리) -# /inspectionProfiles/ - -# 사용자별 UI 설정 -/uiDesigner.xml -/dbnavigator.xml - -# Git 도구창 -/git_toolbox_prj.xml - -# 파일 색상 -/fileColors.xml - -# 스크래치 파일 -/scratches/ - -# 로컬 DB 스키마 -/dbSchemas/ - -# SQL 다이얼렉트 -/sqldialects.xml - -# 프로젝트 레벨 사전 -/dictionaries/ - -# TODO 설정 -/aws.xml - -# 파일 템플릿 -/fileTemplates/ - -# 프로젝트 뷰 -/projectView.xml - -# 스코프 -/scopes/ - -# Rider 특정 설정 -/.idea.*.dir/ -*.iws -/sonarlint/ - -# Avalonia 관련 (사용 시) -/avalonia.xml - -# 사용자 사전 -/shelf.xml - -# NuGet 캐시 (Rider) -/.idea.*/ - -# 파일 인코딩 -/fileEncodings.xml \ No newline at end of file diff --git a/PushAndPull/PushAndPull.sln b/PushAndPull/PushAndPull.sln index 8c63943..65559d0 100644 --- a/PushAndPull/PushAndPull.sln +++ b/PushAndPull/PushAndPull.sln @@ -4,18 +4,47 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{9993E7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{97FF0397-9605-4C0B-AC1F-16B78FAB18B0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{BEA371F4-C6A9-4064-A9AD-3223B84117A9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {97FF0397-9605-4C0B-AC1F-16B78FAB18B0} = {9993E701-2E0B-4C34-B9FA-A660C3DEE75B} + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|x64.Build.0 = Debug|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Debug|x86.Build.0 = Debug|Any CPU {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|Any CPU.ActiveCfg = Release|Any CPU {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|Any CPU.Build.0 = Release|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|x64.ActiveCfg = Release|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|x64.Build.0 = Release|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|x86.ActiveCfg = Release|Any CPU + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0}.Release|x86.Build.0 = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|x64.Build.0 = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Debug|x86.Build.0 = Debug|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|Any CPU.Build.0 = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|x64.ActiveCfg = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|x64.Build.0 = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|x86.ActiveCfg = Release|Any CPU + {BEA371F4-C6A9-4064-A9AD-3223B84117A9}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {97FF0397-9605-4C0B-AC1F-16B78FAB18B0} = {9993E701-2E0B-4C34-B9FA-A660C3DEE75B} EndGlobalSection EndGlobal diff --git a/PushAndPull/Server/Api/Extension/ClaimPrincipalExtension.cs b/PushAndPull/Server/Api/Extension/ClaimsPrincipalExtensions.cs similarity index 78% rename from PushAndPull/Server/Api/Extension/ClaimPrincipalExtension.cs rename to PushAndPull/Server/Api/Extension/ClaimsPrincipalExtensions.cs index 2747120..d386701 100644 --- a/PushAndPull/Server/Api/Extension/ClaimPrincipalExtension.cs +++ b/PushAndPull/Server/Api/Extension/ClaimsPrincipalExtensions.cs @@ -3,7 +3,7 @@ namespace Server.Api.Extension; -public static class ClaimPrincipalExtension +public static class ClaimsPrincipalExtensions { public static string GetSessionId(this ClaimsPrincipal user) { @@ -11,12 +11,12 @@ public static string GetSessionId(this ClaimsPrincipal user) ?? throw new UnauthorizedAccessException("SESSION_ID_MISSING"); } - public static long GetSteamId(this ClaimsPrincipal user) + public static ulong GetSteamId(this ClaimsPrincipal user) { var value = user.FindFirst(SessionClaim.SteamId)?.Value ?? throw new UnauthorizedAccessException("STEAM_ID_MISSING"); - if (!long.TryParse(value, out var steamId)) + if (!ulong.TryParse(value, out var steamId)) throw new UnauthorizedAccessException("INVALID_STEAM_ID"); return steamId; diff --git a/PushAndPull/Server/Infrastructure/Cache/CacheKey.cs b/PushAndPull/Server/Application/Cache/CacheKey.cs similarity index 79% rename from PushAndPull/Server/Infrastructure/Cache/CacheKey.cs rename to PushAndPull/Server/Application/Cache/CacheKey.cs index 7483149..c506560 100644 --- a/PushAndPull/Server/Infrastructure/Cache/CacheKey.cs +++ b/PushAndPull/Server/Application/Cache/CacheKey.cs @@ -1,4 +1,4 @@ -namespace Server.Infrastructure.Cache; +namespace Server.Application.Cache; public static class CacheKey { @@ -6,4 +6,4 @@ public static class Session { public static string ById(string sessionId) => $"session:{sessionId}"; } -} \ No newline at end of file +} diff --git a/PushAndPull/Server/Application/Port/Input/ICreateRoomUseCase.cs b/PushAndPull/Server/Application/Port/Input/ICreateRoomUseCase.cs index 2ab78bd..eacd90c 100644 --- a/PushAndPull/Server/Application/Port/Input/ICreateRoomUseCase.cs +++ b/PushAndPull/Server/Application/Port/Input/ICreateRoomUseCase.cs @@ -10,7 +10,7 @@ public record CreateRoomCommand( string RoomName, bool IsPrivate, string? Password, - long HostSteamId + ulong HostSteamId ); public record CreateRoomResult( diff --git a/PushAndPull/Server/Application/Service/SessionService.cs b/PushAndPull/Server/Application/Service/SessionService.cs index e46e286..8bcdc5b 100644 --- a/PushAndPull/Server/Application/Service/SessionService.cs +++ b/PushAndPull/Server/Application/Service/SessionService.cs @@ -1,6 +1,6 @@ +using Server.Application.Cache; using Server.Application.Port.Output; using Server.Domain.Entity; -using Server.Infrastructure.Cache; namespace Server.Application.Service; @@ -36,11 +36,4 @@ public async Task DeleteAsync(string sessionId) await _cacheStore.DeleteAsync(CacheKey.Session.ById(sessionId)); } - public async Task RemoveSessionAsync(string sessionId) - { - var session = await GetAsync(sessionId); - if (session == null) return; - - await _cacheStore.DeleteAsync(CacheKey.Session.ById(sessionId)); - } } \ No newline at end of file diff --git a/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs b/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs index 60abcad..e32e539 100644 --- a/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs @@ -2,6 +2,7 @@ using Server.Application.Port.Output; using Server.Application.Port.Output.Persistence; using Server.Domain.Entity; +using Server.Domain.Exception.Auth; namespace Server.Application.UseCase.Auth; @@ -26,6 +27,9 @@ public async Task ExecuteAsync(LoginCommand request) { var authResult = await _validator.ValidateAsync(request.Ticket); + if (authResult.IsFamilySharing) + throw new FamilySharingNotAllowedException(authResult.SteamId); + var user = await _userRepository.GetBySteamIdAsync(authResult.SteamId); if (user == null) { @@ -34,7 +38,9 @@ public async Task ExecuteAsync(LoginCommand request) } else { - await _userRepository.UpdateAsync(user.SteamId, request.Nickname, DateTime.UtcNow); + user.UpdateNickname(request.Nickname); + user.UpdateLastLogin(); + await _userRepository.UpdateAsync(user.SteamId, user.Nickname, user.LastLoginAt); } var session = await _sessionService.CreateAsync( diff --git a/PushAndPull/Server/Application/UseCase/Room/GetAllRoomUseCase.cs b/PushAndPull/Server/Application/UseCase/Room/GetAllRoomUseCase.cs index 02b3a8b..afd4057 100644 --- a/PushAndPull/Server/Application/UseCase/Room/GetAllRoomUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Room/GetAllRoomUseCase.cs @@ -19,8 +19,8 @@ public async Task ExecuteAsync(CancellationToken ct = d var summaries = rooms .Select(room => new RoomSummary( - room.RoomCode, room.RoomName, + room.RoomCode, room.CurrentPlayers, room.IsPrivate )) diff --git a/PushAndPull/Server/Domain/Entity/Room.cs b/PushAndPull/Server/Domain/Entity/Room.cs index aa1a153..56996b6 100644 --- a/PushAndPull/Server/Domain/Entity/Room.cs +++ b/PushAndPull/Server/Domain/Entity/Room.cs @@ -9,7 +9,7 @@ public class Room public ulong SteamLobbyId { get; set; } public User Host { get; private set; } - public long HostSteamId { get; set; } + public ulong HostSteamId { get; set; } public int CurrentPlayers { get; set; } public int MaxPlayers { get; private set; } @@ -28,7 +28,7 @@ public Room( string roomCode, string roomName, ulong steamLobbyId, - long hostSteamId, + ulong hostSteamId, bool isPrivate, string? passwordHash ) diff --git a/PushAndPull/Server/Domain/Entity/User.cs b/PushAndPull/Server/Domain/Entity/User.cs index 8b61415..a921075 100644 --- a/PushAndPull/Server/Domain/Entity/User.cs +++ b/PushAndPull/Server/Domain/Entity/User.cs @@ -3,9 +3,9 @@ public class User { public ulong SteamId { get; private set; } - public string Nickname { get; set; } + public string Nickname { get; private set; } public DateTime CreatedAt { get; private set; } - public DateTime LastLoginAt { get; set; } + public DateTime LastLoginAt { get; private set; } private User() { } diff --git a/PushAndPull/Server/Domain/Exception/Auth/FamilySharingNotAllowedException.cs b/PushAndPull/Server/Domain/Exception/Auth/FamilySharingNotAllowedException.cs new file mode 100644 index 0000000..af43842 --- /dev/null +++ b/PushAndPull/Server/Domain/Exception/Auth/FamilySharingNotAllowedException.cs @@ -0,0 +1,9 @@ +namespace Server.Domain.Exception.Auth; + +public class FamilySharingNotAllowedException : SteamAuthException +{ + public FamilySharingNotAllowedException(ulong steamId) + : base("Family sharing is not allowed", steamId) + { + } +} diff --git a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs index 314250f..a09a0ad 100644 --- a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs +++ b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs @@ -22,8 +22,10 @@ IConfiguration configuration ) { _httpClient = httpClient; - _apiKey = configuration["Steam:WebApiKey"] - ?? throw new ArgumentException("API_KEY_REQUIRED"); + var apiKeySecretName = configuration["Steam:WebApiKey"] + ?? throw new ArgumentException("STEAM_API_KEY_SECRET_NAME_REQUIRED"); + _apiKey = configuration[apiKeySecretName] + ?? throw new ArgumentException("STEAM_API_KEY_NOT_FOUND_IN_KEY_VAULT"); if (!int.TryParse(configuration["Steam:AppId"], out _appId)) throw new ArgumentException("APPID_REQUIRED"); diff --git a/PushAndPull/Server/Program.cs b/PushAndPull/Server/Program.cs index 46cf29a..618e3c2 100644 --- a/PushAndPull/Server/Program.cs +++ b/PushAndPull/Server/Program.cs @@ -1,4 +1,5 @@ using Azure.Identity; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.EntityFrameworkCore; using Server.Application.Port.Input; using Server.Application.Port.Output; @@ -6,6 +7,7 @@ using Server.Application.Service; using Server.Application.UseCase.Auth; using Server.Application.UseCase.Room; +using Server.Domain.Exception.Auth; using Server.Infrastructure.Auth; using Server.Infrastructure.Cache; using Server.Infrastructure.Persistence.DbContext; @@ -56,5 +58,19 @@ var app = builder.Build(); +app.UseExceptionHandler(errApp => errApp.Run(ctx => +{ + var feature = ctx.Features.Get(); + ctx.Response.StatusCode = feature?.Error switch + { + FamilySharingNotAllowedException => StatusCodes.Status403Forbidden, + VacBannedException => StatusCodes.Status403Forbidden, + PublisherBannedException => StatusCodes.Status403Forbidden, + InvalidTicketException => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status500InternalServerError + }; + return Task.CompletedTask; +})); + app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/PushAndPull/Server/appsettings.json b/PushAndPull/Server/appsettings.json index 414b7ef..a5213c0 100644 --- a/PushAndPull/Server/appsettings.json +++ b/PushAndPull/Server/appsettings.json @@ -7,7 +7,7 @@ }, "AllowedHosts": "*", "Steam": { - "WebApiKey": "", + "WebApiKey": "Steam-ApiKey", "AppId": 480 }, "KeyVault": { diff --git a/PushAndPull/Tests/Domain/RoomTests.cs b/PushAndPull/Tests/Domain/RoomTests.cs new file mode 100644 index 0000000..bcd17c3 --- /dev/null +++ b/PushAndPull/Tests/Domain/RoomTests.cs @@ -0,0 +1,123 @@ +using Server.Domain.Entity; + +namespace Tests.Domain; + +public class RoomTests +{ + public class WhenAPlayerJoinsAnActiveRoom + { + private readonly Room _room; + + public WhenAPlayerJoinsAnActiveRoom() + { + _room = new Room("ROOM01", "Test Room", 111UL, 76561198000000001UL, false, null); + } + + [Fact] + public void It_IncreasesCurrentPlayerCount() + { + var before = _room.CurrentPlayers; + + _room.Join(); + + Assert.Equal(before + 1, _room.CurrentPlayers); + } + } + + public class WhenAPlayerTriesToJoinAFullRoom + { + private readonly Room _room; + + public WhenAPlayerTriesToJoinAFullRoom() + { + _room = new Room("ROOM02", "Full Room", 222UL, 76561198000000001UL, false, null); + _room.Join(); + } + + [Fact] + public void It_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => _room.Join()); + + Assert.Equal("FULL_ROOM", ex.Message); + } + } + + public class WhenARoomIsMarkedAsDeleting + { + private readonly Room _room; + private readonly TimeSpan _ttl = TimeSpan.FromMinutes(5); + + public WhenARoomIsMarkedAsDeleting() + { + _room = new Room("ROOM03", "Deleting Room", 333UL, 76561198000000001UL, false, null); + } + + [Fact] + public void It_ChangesStatusToDeleting() + { + _room.MarkDeleting(_ttl); + + Assert.Equal(RoomStatus.Deleting, _room.Status); + } + + [Fact] + public void It_SetsExpiresAt() + { + var before = DateTimeOffset.UtcNow; + + _room.MarkDeleting(_ttl); + + Assert.NotNull(_room.ExpiresAt); + Assert.True(_room.ExpiresAt >= before.Add(_ttl)); + } + } + + public class WhenARoomIsClosed + { + private readonly Room _room; + + public WhenARoomIsClosed() + { + _room = new Room("ROOM04", "Closing Room", 444UL, 76561198000000001UL, false, null); + } + + [Fact] + public void It_ChangesStatusToClosed() + { + _room.Close(); + + Assert.Equal(RoomStatus.Closed, _room.Status); + } + + [Fact] + public void It_SetsExpiresAtToNow() + { + var before = DateTimeOffset.UtcNow; + + _room.Close(); + + Assert.NotNull(_room.ExpiresAt); + Assert.True(_room.ExpiresAt >= before); + } + } + + public class WhenARoomIsCreated + { + [Fact] + public void It_StartsWithOnePlayer() + { + var room = new Room("ROOM05", "New Room", 555UL, 76561198000000001UL, false, null); + + Assert.Equal(1, room.CurrentPlayers); + } + + [Fact] + public void It_StartsWithActiveStatus() + { + var room = new Room("ROOM06", "Active Room", 666UL, 76561198000000001UL, false, null); + + Assert.Equal(RoomStatus.Active, room.Status); + } + } +} diff --git a/PushAndPull/Tests/Domain/UserTests.cs b/PushAndPull/Tests/Domain/UserTests.cs new file mode 100644 index 0000000..1140da6 --- /dev/null +++ b/PushAndPull/Tests/Domain/UserTests.cs @@ -0,0 +1,82 @@ +using Server.Domain.Entity; + +namespace Tests.Domain; + +public class UserTests +{ + public class WhenUpdatingNicknameWithAValidValue + { + private readonly User _user; + + public WhenUpdatingNicknameWithAValidValue() + { + _user = new User(76561198000000001UL, "OriginalName"); + } + + [Fact] + public void It_ChangesTheNickname() + { + _user.UpdateNickname("NewName"); + + Assert.Equal("NewName", _user.Nickname); + } + } + + public class WhenUpdatingNicknameWithAnEmptyString + { + private readonly User _user; + + public WhenUpdatingNicknameWithAnEmptyString() + { + _user = new User(76561198000000002UL, "SomePlayer"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void It_ThrowsArgumentException(string invalidNickname) + { + Assert.Throws(() => _user.UpdateNickname(invalidNickname)); + } + } + + public class WhenUpdatingLastLoginTime + { + private readonly User _user; + + public WhenUpdatingLastLoginTime() + { + _user = new User(76561198000000003UL, "LoginPlayer"); + } + + [Fact] + public void It_UpdatesLastLoginAt() + { + var before = _user.LastLoginAt; + + _user.UpdateLastLogin(); + + Assert.True(_user.LastLoginAt >= before); + } + } + + public class WhenAUserIsCreated + { + [Fact] + public void It_SetsTheSteamId() + { + var steamId = 76561198000000004UL; + var user = new User(steamId, "Player"); + + Assert.Equal(steamId, user.SteamId); + } + + [Fact] + public void It_SetsTheNickname() + { + var user = new User(76561198000000005UL, "MyNick"); + + Assert.Equal("MyNick", user.Nickname); + } + } +} diff --git a/PushAndPull/Tests/Tests.csproj b/PushAndPull/Tests/Tests.csproj new file mode 100644 index 0000000..50079e1 --- /dev/null +++ b/PushAndPull/Tests/Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs b/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs new file mode 100644 index 0000000..f4edef4 --- /dev/null +++ b/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs @@ -0,0 +1,127 @@ +using Moq; +using Server.Application.Port.Output; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Auth; +using Server.Domain.Entity; +using Server.Application.Port.Input; + +namespace Tests.UseCase.Auth; + +public class LoginUseCaseTests +{ + public class WhenANewUserLogsInForTheFirstTime + { + private readonly Mock _validatorMock = new(); + private readonly Mock _sessionServiceMock = new(); + private readonly Mock _userRepositoryMock = new(); + private readonly LoginUseCase _sut; + + private const string Ticket = "valid-ticket"; + private const string Nickname = "TestPlayer"; + private const ulong SteamId = 76561198000000001UL; + + public WhenANewUserLogsInForTheFirstTime() + { + _validatorMock + .Setup(v => v.ValidateAsync(Ticket)) + .ReturnsAsync(new AuthTicketValidationResult(SteamId, SteamId, false, false)); + + _userRepositoryMock + .Setup(r => r.GetBySteamIdAsync(SteamId, CancellationToken.None)) + .ReturnsAsync((User?)null); + + var session = new PlayerSession(SteamId, TimeSpan.FromDays(15)); + _sessionServiceMock + .Setup(s => s.CreateAsync(SteamId, TimeSpan.FromDays(15))) + .ReturnsAsync(session); + + _sut = new LoginUseCase(_validatorMock.Object, _sessionServiceMock.Object, _userRepositoryMock.Object); + } + + [Fact] + public async Task It_CreatesANewUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, Nickname)); + + _userRepositoryMock.Verify(r => r.CreateAsync( + It.Is(u => u.SteamId == SteamId && u.Nickname == Nickname), + CancellationToken.None), Times.Once); + } + + [Fact] + public async Task It_ReturnsASessionId() + { + var result = await _sut.ExecuteAsync(new LoginCommand(Ticket, Nickname)); + + Assert.NotEmpty(result.SessionId); + } + + [Fact] + public async Task It_DoesNotCallUpdateUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, Nickname)); + + _userRepositoryMock.Verify(r => r.UpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None), Times.Never); + } + } + + public class WhenAnExistingUserLogsInAgain + { + private readonly Mock _validatorMock = new(); + private readonly Mock _sessionServiceMock = new(); + private readonly Mock _userRepositoryMock = new(); + private readonly LoginUseCase _sut; + + private const string Ticket = "valid-ticket"; + private const string NewNickname = "UpdatedPlayer"; + private const ulong SteamId = 76561198000000002UL; + + public WhenAnExistingUserLogsInAgain() + { + _validatorMock + .Setup(v => v.ValidateAsync(Ticket)) + .ReturnsAsync(new AuthTicketValidationResult(SteamId, SteamId, false, false)); + + var existingUser = new User(SteamId, "OldNickname"); + _userRepositoryMock + .Setup(r => r.GetBySteamIdAsync(SteamId, CancellationToken.None)) + .ReturnsAsync(existingUser); + + var session = new PlayerSession(SteamId, TimeSpan.FromDays(15)); + _sessionServiceMock + .Setup(s => s.CreateAsync(SteamId, TimeSpan.FromDays(15))) + .ReturnsAsync(session); + + _sut = new LoginUseCase(_validatorMock.Object, _sessionServiceMock.Object, _userRepositoryMock.Object); + } + + [Fact] + public async Task It_UpdatesNicknameAndLastLogin() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, NewNickname)); + + _userRepositoryMock.Verify(r => r.UpdateAsync( + SteamId, + NewNickname, + It.IsAny(), + CancellationToken.None), Times.Once); + } + + [Fact] + public async Task It_DoesNotCreateANewUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, NewNickname)); + + _userRepositoryMock.Verify(r => r.CreateAsync(It.IsAny(), CancellationToken.None), Times.Never); + } + + [Fact] + public async Task It_ReturnsASessionId() + { + var result = await _sut.ExecuteAsync(new LoginCommand(Ticket, NewNickname)); + + Assert.NotEmpty(result.SessionId); + } + } +} diff --git a/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs b/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs new file mode 100644 index 0000000..58614fe --- /dev/null +++ b/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs @@ -0,0 +1,34 @@ +using Moq; +using Server.Application.Port.Input; +using Server.Application.Port.Output; +using Server.Application.UseCase.Auth; + +namespace Tests.UseCase.Auth; + +public class LogoutUseCaseTests +{ + public class WhenAUserLogsOutWithAValidSession + { + private readonly Mock _sessionServiceMock = new(); + private readonly LogoutUseCase _sut; + + private const string SessionId = "session-xyz789"; + + public WhenAUserLogsOutWithAValidSession() + { + _sessionServiceMock + .Setup(s => s.DeleteAsync(SessionId)) + .Returns(Task.CompletedTask); + + _sut = new LogoutUseCase(_sessionServiceMock.Object); + } + + [Fact] + public async Task It_DeletesTheSession() + { + await _sut.ExecuteAsync(new LogoutCommand(SessionId)); + + _sessionServiceMock.Verify(s => s.DeleteAsync(SessionId), Times.Once); + } + } +} diff --git a/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs new file mode 100644 index 0000000..732500b --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs @@ -0,0 +1,120 @@ +using Moq; +using Server.Application.Port.Input; +using Server.Application.Port.Output; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Room; +using EntityRoom = Server.Domain.Entity.Room; + +namespace Tests.UseCase.Room; + +public class CreateRoomUseCaseTests +{ + public class WhenCreatingAPublicRoomWithoutAPassword + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _roomCodeGeneratorMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly CreateRoomUseCase _sut; + + private const string GeneratedCode = "ABC123"; + private readonly CreateRoomCommand _command = new( + LobbyId: 111222333UL, + RoomName: "Test Room", + IsPrivate: false, + Password: null, + HostSteamId: 76561198000000001UL + ); + + public WhenCreatingAPublicRoomWithoutAPassword() + { + _roomCodeGeneratorMock.Setup(g => g.Generate()).Returns(GeneratedCode); + + _sut = new CreateRoomUseCase( + _roomRepositoryMock.Object, + _roomCodeGeneratorMock.Object, + _passwordHasherMock.Object + ); + } + + [Fact] + public async Task It_ReturnsTheGeneratedRoomCode() + { + var result = await _sut.ExecuteAsync(_command); + + Assert.Equal(GeneratedCode, result.RoomCode); + } + + [Fact] + public async Task It_SavesTheRoomToRepository() + { + await _sut.ExecuteAsync(_command); + + _roomRepositoryMock.Verify(r => r.CreateAsync( + It.Is(room => + room.RoomCode == GeneratedCode && + room.RoomName == _command.RoomName && + room.IsPrivate == false && + room.PasswordHash == null + )), Times.Once); + } + + [Fact] + public async Task It_DoesNotHashAnyPassword() + { + await _sut.ExecuteAsync(_command); + + _passwordHasherMock.Verify(h => h.Hash(It.IsAny()), Times.Never); + } + } + + public class WhenCreatingAPrivateRoomWithAPassword + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _roomCodeGeneratorMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly CreateRoomUseCase _sut; + + private const string GeneratedCode = "XYZ789"; + private const string RawPassword = "secret1234"; + private const string HashedPassword = "hashed-secret1234"; + + private readonly CreateRoomCommand _command = new( + LobbyId: 999888777UL, + RoomName: "Private Room", + IsPrivate: true, + Password: RawPassword, + HostSteamId: 76561198000000002UL + ); + + public WhenCreatingAPrivateRoomWithAPassword() + { + _roomCodeGeneratorMock.Setup(g => g.Generate()).Returns(GeneratedCode); + _passwordHasherMock.Setup(h => h.Hash(RawPassword)).Returns(HashedPassword); + + _sut = new CreateRoomUseCase( + _roomRepositoryMock.Object, + _roomCodeGeneratorMock.Object, + _passwordHasherMock.Object + ); + } + + [Fact] + public async Task It_SavesTheHashedPassword() + { + await _sut.ExecuteAsync(_command); + + _roomRepositoryMock.Verify(r => r.CreateAsync( + It.Is(room => + room.PasswordHash == HashedPassword + )), Times.Once); + } + + [Fact] + public async Task It_ReturnsTheGeneratedRoomCode() + { + var result = await _sut.ExecuteAsync(_command); + + Assert.Equal(GeneratedCode, result.RoomCode); + } + } +} diff --git a/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs new file mode 100644 index 0000000..b1275cc --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs @@ -0,0 +1,74 @@ +using Moq; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Room; +using EntityRoom = Server.Domain.Entity.Room; + +namespace Tests.UseCase.Room; + +public class GetAllRoomUseCaseTests +{ + public class WhenMultipleActiveRoomsExist + { + private readonly Mock _roomRepositoryMock = new(); + private readonly GetAllRoomUseCase _sut; + + private readonly IReadOnlyList _rooms; + + public WhenMultipleActiveRoomsExist() + { + _rooms = new List + { + new EntityRoom("AAA001", "Room A", 111UL, 76561198000000001UL, false, null), + new EntityRoom("BBB002", "Room B", 222UL, 76561198000000002UL, true, "hash"), + }; + + _roomRepositoryMock + .Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(_rooms); + + _sut = new GetAllRoomUseCase(_roomRepositoryMock.Object); + } + + [Fact] + public async Task It_ReturnsAllRooms() + { + var result = await _sut.ExecuteAsync(); + + Assert.Equal(_rooms.Count, result.Rooms.Count); + } + + [Fact] + public async Task It_ReturnsCorrectRoomSummaries() + { + var result = await _sut.ExecuteAsync(); + + Assert.Equal("Room A", result.Rooms[0].RoomName); + Assert.Equal("AAA001", result.Rooms[0].RoomCode); + Assert.Equal("Room B", result.Rooms[1].RoomName); + Assert.Equal("BBB002", result.Rooms[1].RoomCode); + } + } + + public class WhenNoRoomsExist + { + private readonly Mock _roomRepositoryMock = new(); + private readonly GetAllRoomUseCase _sut; + + public WhenNoRoomsExist() + { + _roomRepositoryMock + .Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(new List()); + + _sut = new GetAllRoomUseCase(_roomRepositoryMock.Object); + } + + [Fact] + public async Task It_ReturnsAnEmptyList() + { + var result = await _sut.ExecuteAsync(); + + Assert.Empty(result.Rooms); + } + } +} diff --git a/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs new file mode 100644 index 0000000..1c92494 --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs @@ -0,0 +1,85 @@ +using Moq; +using Server.Application.Port.Input; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Room; +using Server.Domain.Exception.Room; +using EntityRoom = Server.Domain.Entity.Room; + +namespace Tests.UseCase.Room; + +public class GetRoomUseCaseTests +{ + public class WhenAnEmptyRoomCodeIsProvided + { + private readonly Mock _roomRepositoryMock = new(); + private readonly GetRoomUseCase _sut; + + public WhenAnEmptyRoomCodeIsProvided() + { + _sut = new GetRoomUseCase(_roomRepositoryMock.Object); + } + + [Fact] + public async Task It_ThrowsArgumentException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new GetRoomCommand(""))); + } + } + + public class WhenTheRoomDoesNotExist + { + private readonly Mock _roomRepositoryMock = new(); + private readonly GetRoomUseCase _sut; + + private const string RoomCode = "NOTFOUND"; + + public WhenTheRoomDoesNotExist() + { + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync((EntityRoom?)null); + + _sut = new GetRoomUseCase(_roomRepositoryMock.Object); + } + + [Fact] + public async Task It_ThrowsRoomNotFoundException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new GetRoomCommand(RoomCode))); + } + } + + public class WhenTheRoomExists + { + private readonly Mock _roomRepositoryMock = new(); + private readonly GetRoomUseCase _sut; + + private const string RoomCode = "EXIST1"; + private const string RoomName = "Existing Room"; + private readonly EntityRoom _room; + + public WhenTheRoomExists() + { + _room = new EntityRoom(RoomCode, RoomName, 444UL, 76561198000000001UL, false, null); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(_room); + + _sut = new GetRoomUseCase(_roomRepositoryMock.Object); + } + + [Fact] + public async Task It_ReturnsRoomInfo() + { + var result = await _sut.ExecuteAsync(new GetRoomCommand(RoomCode)); + + Assert.Equal(RoomCode, result.RoomCode); + Assert.Equal(RoomName, result.RoomName); + Assert.Equal(_room.CurrentPlayers, result.CurrentPlayers); + Assert.Equal(_room.IsPrivate, result.IsPrivate); + } + } +} diff --git a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs new file mode 100644 index 0000000..da7d018 --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs @@ -0,0 +1,140 @@ +using Moq; +using Server.Application.Port.Input; +using Server.Application.Port.Output; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Room; +using Server.Domain.Exception.Room; +using EntityRoom = Server.Domain.Entity.Room; + +namespace Tests.UseCase.Room; + +public class JoinRoomUseCaseTests +{ + public class WhenTheRoomDoesNotExist + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "NOTEXIST"; + + public WhenTheRoomDoesNotExist() + { + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync((EntityRoom?)null); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsRoomNotFoundException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + } + } + + public class WhenTheRoomIsNotActive + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "CLOSED1"; + + public WhenTheRoomIsNotActive() + { + var closedRoom = new EntityRoom("CLOSED1", "Closed Room", 111UL, 76561198000000001UL, false, null); + closedRoom.Close(); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(closedRoom); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsRoomNotActiveException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + } + } + + public class WhenTheWrongPasswordIsProvidedForAPrivateRoom + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "PRIV01"; + private const string WrongPassword = "wrong-password"; + private const string StoredHash = "correct-hash"; + + public WhenTheWrongPasswordIsProvidedForAPrivateRoom() + { + var privateRoom = new EntityRoom(RoomCode, "Private Room", 222UL, 76561198000000001UL, true, StoredHash); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(privateRoom); + + _passwordHasherMock + .Setup(h => h.Verify(WrongPassword, StoredHash)) + .Returns(false); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsInvalidOperationExceptionWithInvalidPasswordMessage() + { + var ex = await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, WrongPassword))); + + Assert.Equal("INVALID_PASSWORD", ex.Message); + } + } + + public class WhenAllConditionsAreValidForJoiningARoom + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "OPEN01"; + private readonly EntityRoom _activeRoom; + + public WhenAllConditionsAreValidForJoiningARoom() + { + _activeRoom = new EntityRoom(RoomCode, "Open Room", 333UL, 76561198000000001UL, false, null); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(_activeRoom); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_UpdatesTheRoom() + { + await _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null)); + + _roomRepositoryMock.Verify(r => r.UpdateAsync( + It.Is(room => room.RoomCode == RoomCode)), Times.Once); + } + + [Fact] + public async Task It_IncreasesCurrentPlayerCount() + { + var before = _activeRoom.CurrentPlayers; + + await _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null)); + + Assert.Equal(before + 1, _activeRoom.CurrentPlayers); + } + } +}