From f93c631a9c457e7a1c3f673bc4392927174136a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PARK=20HYEONMIN=20=28=EB=B0=95=ED=98=84=EB=AF=BC=29?= Date: Sun, 8 Mar 2026 15:36:38 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20CacheKey=EB=A5=BC=20Applicati?= =?UTF-8?q?on=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../{Infrastructure => Application}/Cache/CacheKey.cs | 4 ++-- PushAndPull/Server/Application/Service/SessionService.cs | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) rename PushAndPull/Server/{Infrastructure => Application}/Cache/CacheKey.cs (79%) 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/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 From fcf6fa52de42925f44d01ac8ab1763fa0d6ed33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PARK=20HYEONMIN=20=28=EB=B0=95=ED=98=84=EB=AF=BC=29?= Date: Sun, 8 Mar 2026 15:36:46 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20SteamId=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20long=EC=97=90=EC=84=9C=20ulong=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...imPrincipalExtension.cs => ClaimsPrincipalExtensions.cs} | 6 +++--- .../Server/Application/Port/Input/ICreateRoomUseCase.cs | 2 +- PushAndPull/Server/Domain/Entity/Room.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename PushAndPull/Server/Api/Extension/{ClaimPrincipalExtension.cs => ClaimsPrincipalExtensions.cs} (78%) 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/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/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 ) From a418af2b734a8ef38a75e80a6b6b4ef165d96953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PARK=20HYEONMIN=20=28=EB=B0=95=ED=98=84=EB=AF=BC=29?= Date: Sun, 8 Mar 2026 15:36:50 +0900 Subject: [PATCH 03/17] =?UTF-8?q?refactor:=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=BA=A1=EC=8A=90=ED=99=94=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=ED=95=B4=20=EC=83=81=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs | 4 +++- PushAndPull/Server/Domain/Entity/User.cs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs b/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs index 60abcad..50b2172 100644 --- a/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs @@ -34,7 +34,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/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() { } From 51840caa003cd426e7b19676f62c05196348e2fe Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 9 Mar 2026 09:34:12 +0900 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20.gitignore=EC=97=90=20JetBrains=20?= =?UTF-8?q?IDE=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/.gitignore | 119 +---------------- .../.idea/.idea.PushAndPull/.idea/.gitignore | 120 ------------------ 2 files changed, 4 insertions(+), 235 deletions(-) delete mode 100644 PushAndPull/.idea/.idea.PushAndPull/.idea/.gitignore diff --git a/PushAndPull/.gitignore b/PushAndPull/.gitignore index b4e9c7c..123cd1f 100644 --- a/PushAndPull/.gitignore +++ b/PushAndPull/.gitignore @@ -7,119 +7,8 @@ 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.*/ - -# 파일 인코딩 -/fileEncodings.xml \ No newline at end of file +*.iml +*.ipr \ 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 From 022408cb94d2cd51a9da4e3a1204a136b23317f6 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 9 Mar 2026 10:22:20 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20xUnit=20+=20Moq=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20GetAllRoomUseCase=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DCI(Describe-Context-It) 패턴으로 단위 테스트 작성 - UseCase 테스트: LoginUseCase, LogoutUseCase, CreateRoomUseCase, JoinRoomUseCase, GetRoomUseCase, GetAllRoomUseCase - Domain 테스트: Room, User 엔티티 도메인 로직 검증 - fix: GetAllRoomUseCase에서 RoomSummary 생성 시 RoomCode/RoomName 순서 오류 수정 Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/PushAndPull.sln | 35 ++++- .../UseCase/Room/GetAllRoomUseCase.cs | 2 +- PushAndPull/Tests/Domain/RoomTests.cs | 129 ++++++++++++++++ PushAndPull/Tests/Domain/UserTests.cs | 87 +++++++++++ PushAndPull/Tests/Tests.csproj | 26 ++++ .../Tests/UseCase/Auth/LoginUseCaseTests.cs | 130 ++++++++++++++++ .../Tests/UseCase/Auth/LogoutUseCaseTests.cs | 36 +++++ .../UseCase/Room/CreateRoomUseCaseTests.cs | 123 +++++++++++++++ .../UseCase/Room/GetAllRoomUseCaseTests.cs | 77 ++++++++++ .../Tests/UseCase/Room/GetRoomUseCaseTests.cs | 89 +++++++++++ .../UseCase/Room/JoinRoomUseCaseTests.cs | 145 ++++++++++++++++++ 11 files changed, 875 insertions(+), 4 deletions(-) create mode 100644 PushAndPull/Tests/Domain/RoomTests.cs create mode 100644 PushAndPull/Tests/Domain/UserTests.cs create mode 100644 PushAndPull/Tests/Tests.csproj create mode 100644 PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs create mode 100644 PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs create mode 100644 PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs create mode 100644 PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs create mode 100644 PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs create mode 100644 PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs 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/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/Tests/Domain/RoomTests.cs b/PushAndPull/Tests/Domain/RoomTests.cs new file mode 100644 index 0000000..3678ba4 --- /dev/null +++ b/PushAndPull/Tests/Domain/RoomTests.cs @@ -0,0 +1,129 @@ +using Server.Domain.Entity; + +namespace Tests.Domain; + +// Describe: Room +public class RoomTests +{ + // Context: When a player joins an active room + 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); + } + } + + // Context: When a player tries to join a full room + public class WhenAPlayerTriesToJoinAFullRoom + { + private readonly Room _room; + + public WhenAPlayerTriesToJoinAFullRoom() + { + _room = new Room("ROOM02", "Full Room", 222UL, 76561198000000001UL, false, null); + _room.Join(); // MaxPlayers = 2, now full + } + + [Fact] + public void It_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => _room.Join()); + + Assert.Equal("FULL_ROOM", ex.Message); + } + } + + // Context: When a room is marked as deleting + 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)); + } + } + + // Context: When a room is closed + 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); + } + } + + // Context: When a room is created + 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..bcc2d08 --- /dev/null +++ b/PushAndPull/Tests/Domain/UserTests.cs @@ -0,0 +1,87 @@ +using Server.Domain.Entity; + +namespace Tests.Domain; + +// Describe: User +public class UserTests +{ + // Context: When updating a nickname with a valid value + 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); + } + } + + // Context: When updating a nickname with an empty string + 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)); + } + } + + // Context: When updating the last login time + 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); + } + } + + // Context: When a user is created + 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..2010bda --- /dev/null +++ b/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs @@ -0,0 +1,130 @@ +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; + +// Describe: LoginUseCase +public class LoginUseCaseTests +{ + // Context: When a new user logs in for the first time + 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); + } + } + + // Context: When an existing user logs in again + 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..c7351fb --- /dev/null +++ b/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs @@ -0,0 +1,36 @@ +using Moq; +using Server.Application.Port.Input; +using Server.Application.Port.Output; +using Server.Application.UseCase.Auth; + +namespace Tests.UseCase.Auth; + +// Describe: LogoutUseCase +public class LogoutUseCaseTests +{ + // Context: When a user logs out with a valid session + 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..a191ee7 --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs @@ -0,0 +1,123 @@ +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; + +// Describe: CreateRoomUseCase +public class CreateRoomUseCaseTests +{ + // Context: When creating a public room without a password + 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); + } + } + + // Context: When creating a private room with a password + 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..f1c6778 --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs @@ -0,0 +1,77 @@ +using Moq; +using Server.Application.Port.Output.Persistence; +using Server.Application.UseCase.Room; +using EntityRoom = Server.Domain.Entity.Room; + +namespace Tests.UseCase.Room; + +// Describe: GetAllRoomUseCase +public class GetAllRoomUseCaseTests +{ + // Context: When multiple active rooms exist + 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); + } + } + + // Context: When no rooms exist + 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..40cf1ba --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs @@ -0,0 +1,89 @@ +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; + +// Describe: GetRoomUseCase +public class GetRoomUseCaseTests +{ + // Context: When an empty room code is provided + 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(""))); + } + } + + // Context: When the room does not exist + 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))); + } + } + + // Context: When the room exists + 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..44d6ebd --- /dev/null +++ b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs @@ -0,0 +1,145 @@ +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; + +// Describe: JoinRoomUseCase +public class JoinRoomUseCaseTests +{ + // Context: When the room does not exist + 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))); + } + } + + // Context: When the room is not active + 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))); + } + } + + // Context: When the wrong password is provided for a private room + 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); + } + } + + // Context: When all conditions are valid for joining a room + 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); + } + } +} From aa00be0095a21ce863f32e3054a6900c3303b867 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 9 Mar 2026 10:31:50 +0900 Subject: [PATCH 06/17] =?UTF-8?q?modify:=20=EC=A3=BC=EC=84=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/Tests/Domain/RoomTests.cs | 8 +------- PushAndPull/Tests/Domain/UserTests.cs | 5 ----- PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs | 3 --- PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs | 2 -- PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs | 3 --- PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs | 3 --- PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs | 4 ---- PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs | 5 ----- 8 files changed, 1 insertion(+), 32 deletions(-) diff --git a/PushAndPull/Tests/Domain/RoomTests.cs b/PushAndPull/Tests/Domain/RoomTests.cs index 3678ba4..bcd17c3 100644 --- a/PushAndPull/Tests/Domain/RoomTests.cs +++ b/PushAndPull/Tests/Domain/RoomTests.cs @@ -2,10 +2,8 @@ namespace Tests.Domain; -// Describe: Room public class RoomTests { - // Context: When a player joins an active room public class WhenAPlayerJoinsAnActiveRoom { private readonly Room _room; @@ -26,7 +24,6 @@ public void It_IncreasesCurrentPlayerCount() } } - // Context: When a player tries to join a full room public class WhenAPlayerTriesToJoinAFullRoom { private readonly Room _room; @@ -34,7 +31,7 @@ public class WhenAPlayerTriesToJoinAFullRoom public WhenAPlayerTriesToJoinAFullRoom() { _room = new Room("ROOM02", "Full Room", 222UL, 76561198000000001UL, false, null); - _room.Join(); // MaxPlayers = 2, now full + _room.Join(); } [Fact] @@ -46,7 +43,6 @@ public void It_ThrowsInvalidOperationException() } } - // Context: When a room is marked as deleting public class WhenARoomIsMarkedAsDeleting { private readonly Room _room; @@ -77,7 +73,6 @@ public void It_SetsExpiresAt() } } - // Context: When a room is closed public class WhenARoomIsClosed { private readonly Room _room; @@ -107,7 +102,6 @@ public void It_SetsExpiresAtToNow() } } - // Context: When a room is created public class WhenARoomIsCreated { [Fact] diff --git a/PushAndPull/Tests/Domain/UserTests.cs b/PushAndPull/Tests/Domain/UserTests.cs index bcc2d08..1140da6 100644 --- a/PushAndPull/Tests/Domain/UserTests.cs +++ b/PushAndPull/Tests/Domain/UserTests.cs @@ -2,10 +2,8 @@ namespace Tests.Domain; -// Describe: User public class UserTests { - // Context: When updating a nickname with a valid value public class WhenUpdatingNicknameWithAValidValue { private readonly User _user; @@ -24,7 +22,6 @@ public void It_ChangesTheNickname() } } - // Context: When updating a nickname with an empty string public class WhenUpdatingNicknameWithAnEmptyString { private readonly User _user; @@ -43,7 +40,6 @@ public void It_ThrowsArgumentException(string invalidNickname) } } - // Context: When updating the last login time public class WhenUpdatingLastLoginTime { private readonly User _user; @@ -64,7 +60,6 @@ public void It_UpdatesLastLoginAt() } } - // Context: When a user is created public class WhenAUserIsCreated { [Fact] diff --git a/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs b/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs index 2010bda..f4edef4 100644 --- a/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Auth/LoginUseCaseTests.cs @@ -7,10 +7,8 @@ namespace Tests.UseCase.Auth; -// Describe: LoginUseCase public class LoginUseCaseTests { - // Context: When a new user logs in for the first time public class WhenANewUserLogsInForTheFirstTime { private readonly Mock _validatorMock = new(); @@ -68,7 +66,6 @@ public async Task It_DoesNotCallUpdateUser() } } - // Context: When an existing user logs in again public class WhenAnExistingUserLogsInAgain { private readonly Mock _validatorMock = new(); diff --git a/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs b/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs index c7351fb..58614fe 100644 --- a/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Auth/LogoutUseCaseTests.cs @@ -5,10 +5,8 @@ namespace Tests.UseCase.Auth; -// Describe: LogoutUseCase public class LogoutUseCaseTests { - // Context: When a user logs out with a valid session public class WhenAUserLogsOutWithAValidSession { private readonly Mock _sessionServiceMock = new(); diff --git a/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs index a191ee7..732500b 100644 --- a/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/CreateRoomUseCaseTests.cs @@ -7,10 +7,8 @@ namespace Tests.UseCase.Room; -// Describe: CreateRoomUseCase public class CreateRoomUseCaseTests { - // Context: When creating a public room without a password public class WhenCreatingAPublicRoomWithoutAPassword { private readonly Mock _roomRepositoryMock = new(); @@ -69,7 +67,6 @@ public async Task It_DoesNotHashAnyPassword() } } - // Context: When creating a private room with a password public class WhenCreatingAPrivateRoomWithAPassword { private readonly Mock _roomRepositoryMock = new(); diff --git a/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs index f1c6778..b1275cc 100644 --- a/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/GetAllRoomUseCaseTests.cs @@ -5,10 +5,8 @@ namespace Tests.UseCase.Room; -// Describe: GetAllRoomUseCase public class GetAllRoomUseCaseTests { - // Context: When multiple active rooms exist public class WhenMultipleActiveRoomsExist { private readonly Mock _roomRepositoryMock = new(); @@ -51,7 +49,6 @@ public async Task It_ReturnsCorrectRoomSummaries() } } - // Context: When no rooms exist public class WhenNoRoomsExist { private readonly Mock _roomRepositoryMock = new(); diff --git a/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs index 40cf1ba..1c92494 100644 --- a/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/GetRoomUseCaseTests.cs @@ -7,10 +7,8 @@ namespace Tests.UseCase.Room; -// Describe: GetRoomUseCase public class GetRoomUseCaseTests { - // Context: When an empty room code is provided public class WhenAnEmptyRoomCodeIsProvided { private readonly Mock _roomRepositoryMock = new(); @@ -29,7 +27,6 @@ await Assert.ThrowsAsync( } } - // Context: When the room does not exist public class WhenTheRoomDoesNotExist { private readonly Mock _roomRepositoryMock = new(); @@ -54,7 +51,6 @@ await Assert.ThrowsAsync( } } - // Context: When the room exists public class WhenTheRoomExists { private readonly Mock _roomRepositoryMock = new(); diff --git a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs index 44d6ebd..da7d018 100644 --- a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs @@ -8,10 +8,8 @@ namespace Tests.UseCase.Room; -// Describe: JoinRoomUseCase public class JoinRoomUseCaseTests { - // Context: When the room does not exist public class WhenTheRoomDoesNotExist { private readonly Mock _roomRepositoryMock = new(); @@ -37,7 +35,6 @@ await Assert.ThrowsAsync( } } - // Context: When the room is not active public class WhenTheRoomIsNotActive { private readonly Mock _roomRepositoryMock = new(); @@ -66,7 +63,6 @@ await Assert.ThrowsAsync( } } - // Context: When the wrong password is provided for a private room public class WhenTheWrongPasswordIsProvidedForAPrivateRoom { private readonly Mock _roomRepositoryMock = new(); @@ -102,7 +98,6 @@ public async Task It_ThrowsInvalidOperationExceptionWithInvalidPasswordMessage() } } - // Context: When all conditions are valid for joining a room public class WhenAllConditionsAreValidForJoiningARoom { private readonly Mock _roomRepositoryMock = new(); From 92db211979ea49661630d8b7267719dd178312d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PARK=20HYEONMIN=20=28=EB=B0=95=ED=98=84=EB=AF=BC=29?= Date: Mon, 9 Mar 2026 11:44:36 +0900 Subject: [PATCH 07/17] Add CI workflow for build and test --- .github/workflows/test.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/test.yaml 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" From 4cb8db3097af573bcac6a08ffeb288126404315e Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 10:58:08 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20Steam=20WebApiKey=20Azure=20Key?= =?UTF-8?q?=20Vault=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=ED=8C=A8=EB=B0=80?= =?UTF-8?q?=EB=A6=AC=20=EC=89=90=EC=96=B4=EB=A7=81=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/Server/Api/Controller/AuthController.cs | 1 + .../Server/Application/UseCase/Auth/LoginUseCase.cs | 4 ++++ .../Exception/Auth/FamilySharingNotAllowedException.cs | 9 +++++++++ .../Infrastructure/Auth/SteamAuthTicketValidator.cs | 4 +++- PushAndPull/Server/appsettings.json | 2 +- 5 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 PushAndPull/Server/Domain/Exception/Auth/FamilySharingNotAllowedException.cs diff --git a/PushAndPull/Server/Api/Controller/AuthController.cs b/PushAndPull/Server/Api/Controller/AuthController.cs index 9020372..5e16ce8 100644 --- a/PushAndPull/Server/Api/Controller/AuthController.cs +++ b/PushAndPull/Server/Api/Controller/AuthController.cs @@ -28,6 +28,7 @@ public async Task> Login( [FromBody] LoginRequest request ) { + if (request == null) throw new ArgumentNullException(nameof(request)); var result = await _loginUseCase.ExecuteAsync(new LoginCommand( request.SteamTicket, request.Nickname diff --git a/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs b/PushAndPull/Server/Application/UseCase/Auth/LoginUseCase.cs index 50b2172..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) { 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..ada63f4 100644 --- a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs +++ b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs @@ -22,7 +22,9 @@ IConfiguration configuration ) { _httpClient = httpClient; - _apiKey = configuration["Steam:WebApiKey"] + var apiKeySecretName = configuration["Steam:WebApiKey"] + ?? throw new ArgumentException("API_KEY_REQUIRED"); + _apiKey = configuration[apiKeySecretName] ?? throw new ArgumentException("API_KEY_REQUIRED"); if (!int.TryParse(configuration["Steam:AppId"], out _appId)) 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": { From 331d31dd83421f13481bf4b868df0695aa107f0a Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 12:26:50 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20Claude=20Code=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/commands/build.md | 16 +++++ PushAndPull/.claude/commands/commit.md | 49 ++++++++++++++ PushAndPull/.claude/commands/migrate.md | 24 +++++++ PushAndPull/.claude/commands/pr.md | 88 +++++++++++++++++++++++++ PushAndPull/.claude/commands/test.md | 16 +++++ PushAndPull/.claude/settings.json | 3 + 6 files changed, 196 insertions(+) create mode 100644 PushAndPull/.claude/commands/build.md create mode 100644 PushAndPull/.claude/commands/commit.md create mode 100644 PushAndPull/.claude/commands/migrate.md create mode 100644 PushAndPull/.claude/commands/pr.md create mode 100644 PushAndPull/.claude/commands/test.md create mode 100644 PushAndPull/.claude/settings.json 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..c17163e --- /dev/null +++ b/PushAndPull/.claude/commands/pr.md @@ -0,0 +1,88 @@ +--- +description: Generate PR title suggestions and body based on changes from develop +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Write +--- + +Generate a PR title and body for the current branch based on changes from `develop`. + +## Context + +- Current branch: !`git branch --show-current` +- Commits from develop: !`git log develop..HEAD --oneline` +- File change stats from develop: !`git diff develop...HEAD --stat` +- Detailed diff from develop: !`git diff develop...HEAD` + +## 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 +- `release` — release (use format `release/x.x.x`) + +**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] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* +``` + +## Your Task + +1. **Suggest 3 PR titles** following the convention above. + +2. **Write the PR body**: + - Analyze commits and diffs from develop + - Fill in `작업 내용` with a concise bullet list of what changed + - 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) + +3. **Save to file** using the Write tool: + - Path: `PR_BODY.md` + - Overwrite if it already exists + +4. **Output** in this format: + ``` + ## 추천 PR 제목 + + 1. [title1] + 2. [title2] + 3. [title3] + + ## PR 본문 (PR_BODY.md에 저장됨) + + [full body preview] + ``` 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": {} +} From 228f23e18d435a2e671f04e987f76f9de7596984 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 13:38:50 +0900 Subject: [PATCH 10/17] =?UTF-8?q?modify:=20PR=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=20PR=20=EC=83=9D=EC=84=B1=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=ED=99=94=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/commands/pr.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/commands/pr.md index c17163e..eb5585e 100644 --- a/PushAndPull/.claude/commands/pr.md +++ b/PushAndPull/.claude/commands/pr.md @@ -1,6 +1,6 @@ --- description: Generate PR title suggestions and body based on changes from develop -allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Write +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(gh pr create:*), Write, AskUserQuestion --- Generate a PR title and body for the current branch based on changes from `develop`. @@ -86,3 +86,15 @@ Follow this exact structure (keep the emoji headers as-is): [full body preview] ``` + +5. **Ask the user** using the AskUserQuestion tool: + - "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" + +6. **Create the PR** using the chosen title: + - If the user answered 1, 2, or 3, use the corresponding suggested title + - If the user typed a custom title, use it as-is + - Run: + ```bash + gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop + ``` + - Output the PR URL when done From 49b97eaacbb327553627fe8252b5cbe3220a4253 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 13:38:53 +0900 Subject: [PATCH 11/17] =?UTF-8?q?modify:=20PR=5FBODY.md=20gitignore?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.gitignore b/PushAndPull/.gitignore index 123cd1f..f9c8857 100644 --- a/PushAndPull/.gitignore +++ b/PushAndPull/.gitignore @@ -1,4 +1,7 @@ -# .NET build output +# Claude Code +PR_BODY.md + +# .NET build output bin/ obj/ From ab7ec3d0bef690370fe800c9da89228340f7ebea Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 13:57:53 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EB=B6=84=20=EB=B0=8F=20=EA=B8=80?= =?UTF-8?q?=EB=A1=9C=EB=B2=8C=20=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Server/Api/Controller/AuthController.cs | 1 - .../Auth/SteamAuthTicketValidator.cs | 4 ++-- PushAndPull/Server/Program.cs | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/PushAndPull/Server/Api/Controller/AuthController.cs b/PushAndPull/Server/Api/Controller/AuthController.cs index 5e16ce8..9020372 100644 --- a/PushAndPull/Server/Api/Controller/AuthController.cs +++ b/PushAndPull/Server/Api/Controller/AuthController.cs @@ -28,7 +28,6 @@ public async Task> Login( [FromBody] LoginRequest request ) { - if (request == null) throw new ArgumentNullException(nameof(request)); var result = await _loginUseCase.ExecuteAsync(new LoginCommand( request.SteamTicket, request.Nickname diff --git a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs index ada63f4..a09a0ad 100644 --- a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs +++ b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs @@ -23,9 +23,9 @@ IConfiguration configuration { _httpClient = httpClient; var apiKeySecretName = configuration["Steam:WebApiKey"] - ?? throw new ArgumentException("API_KEY_REQUIRED"); + ?? throw new ArgumentException("STEAM_API_KEY_SECRET_NAME_REQUIRED"); _apiKey = configuration[apiKeySecretName] - ?? throw new ArgumentException("API_KEY_REQUIRED"); + ?? 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..06d9323 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,17 @@ var app = builder.Build(); +app.UseExceptionHandler(errApp => errApp.Run(async ctx => +{ + var feature = ctx.Features.Get(); + ctx.Response.StatusCode = feature?.Error switch + { + FamilySharingNotAllowedException => StatusCodes.Status403Forbidden, + VacBannedException => StatusCodes.Status403Forbidden, + InvalidTicketException => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status500InternalServerError + }; +})); + app.MapControllers(); app.Run(); \ No newline at end of file From dbb1b47a13745c708270ccd3e9bea6e17f545fda Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 13:59:11 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20DotSettings.user=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.gitignore b/PushAndPull/.gitignore index 123cd1f..509ccaf 100644 --- a/PushAndPull/.gitignore +++ b/PushAndPull/.gitignore @@ -11,4 +11,5 @@ project.nuget.cache .idea/ *.iws *.iml -*.ipr \ No newline at end of file +*.ipr +*.DotSettings.user \ No newline at end of file From ddf1713a9e06c89c522f5170aacebb2c0cedc84f Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 14:04:47 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20Task=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/Server/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PushAndPull/Server/Program.cs b/PushAndPull/Server/Program.cs index 06d9323..618e3c2 100644 --- a/PushAndPull/Server/Program.cs +++ b/PushAndPull/Server/Program.cs @@ -58,16 +58,18 @@ var app = builder.Build(); -app.UseExceptionHandler(errApp => errApp.Run(async ctx => +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(); From e30bce657cec9737992896cf0db407e707cda100 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 14:17:30 +0900 Subject: [PATCH 15/17] =?UTF-8?q?modify:=20PR=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=20PR=5FBODY.md=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/commands/pr.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/commands/pr.md index eb5585e..65588a0 100644 --- a/PushAndPull/.claude/commands/pr.md +++ b/PushAndPull/.claude/commands/pr.md @@ -1,6 +1,6 @@ --- description: Generate PR title suggestions and body based on changes from develop -allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(gh pr create:*), Write, AskUserQuestion +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion --- Generate a PR title and body for the current branch based on changes from `develop`. @@ -98,3 +98,9 @@ Follow this exact structure (keep the emoji headers as-is): gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop ``` - Output the PR URL when done + +7. **Delete PR_BODY.md** after the PR is created: + - Run: + ```bash + rm PR_BODY.md + ``` From 7427914e6592ed4baef6c912d475ffd394cd867e Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 14:18:00 +0900 Subject: [PATCH 16/17] =?UTF-8?q?modify:=20IDE=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20gitignore?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.gitignore b/PushAndPull/.gitignore index f9c8857..431aaac 100644 --- a/PushAndPull/.gitignore +++ b/PushAndPull/.gitignore @@ -14,4 +14,7 @@ project.nuget.cache .idea/ *.iws *.iml -*.ipr \ No newline at end of file +*.ipr + +# Visual Studio user settings +*.DotSettings.user \ No newline at end of file From c84f1becd46edca6e0366c8ca8cbd0ae5c9c1a10 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 11 Mar 2026 15:05:30 +0900 Subject: [PATCH 17/17] =?UTF-8?q?modify:=20PR=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=EB=B8=8C=EB=9E=9C=EC=B9=98=EB=B3=84=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EB=B6=84=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/commands/pr.md | 189 +++++++++++++++++++++-------- 1 file changed, 137 insertions(+), 52 deletions(-) diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/commands/pr.md index 65588a0..20f7d05 100644 --- a/PushAndPull/.claude/commands/pr.md +++ b/PushAndPull/.claude/commands/pr.md @@ -1,16 +1,140 @@ --- description: Generate PR title suggestions and body based on changes from develop -allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion +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 title and body for the current branch based on changes from `develop`. +Generate a PR based on the current branch. Behavior differs depending on the branch. ## Context - Current branch: !`git branch --show-current` -- Commits from develop: !`git log develop..HEAD --oneline` -- File change stats from develop: !`git diff develop...HEAD --stat` -- Detailed diff from develop: !`git diff develop...HEAD` + +--- + +## 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 @@ -21,7 +145,6 @@ Format: `{type}: {Korean description}` - `fix` — bug fix or missing configuration/DI registration - `modify` — modification to existing code - `refactor` — refactoring without behavior change -- `release` — release (use format `release/x.x.x`) **Rules:** - Description in Korean @@ -33,6 +156,8 @@ Format: `{type}: {Korean description}` - `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` - `refactor: 로그인 로직 리팩토링` +--- + ## PR Body Template Follow this exact structure (keep the emoji headers as-is): @@ -58,49 +183,9 @@ Follow this exact structure (keep the emoji headers as-is): > *추후 필요한 체크리스트는 업데이트 될 예정입니다.* ``` -## Your Task - -1. **Suggest 3 PR titles** following the convention above. - -2. **Write the PR body**: - - Analyze commits and diffs from develop - - Fill in `작업 내용` with a concise bullet list of what changed - - 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) - -3. **Save to file** using the Write tool: - - Path: `PR_BODY.md` - - Overwrite if it already exists - -4. **Output** in this format: - ``` - ## 추천 PR 제목 - - 1. [title1] - 2. [title2] - 3. [title3] - - ## PR 본문 (PR_BODY.md에 저장됨) - - [full body preview] - ``` - -5. **Ask the user** using the AskUserQuestion tool: - - "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" - -6. **Create the PR** using the chosen title: - - If the user answered 1, 2, or 3, use the corresponding suggested title - - If the user typed a custom title, use it as-is - - Run: - ```bash - gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop - ``` - - Output the PR URL when done - -7. **Delete PR_BODY.md** after the PR is created: - - Run: - ```bash - rm PR_BODY.md - ``` +**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)