From 9cd37d2ee3c8e9c0f4d87d06315ff7a642d2bdc2 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 19:47:33 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20Npgsql=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/PushAndPull/PushAndPull.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PushAndPull/PushAndPull/PushAndPull.csproj b/PushAndPull/PushAndPull/PushAndPull.csproj index f2f222d..b574c0d 100644 --- a/PushAndPull/PushAndPull/PushAndPull.csproj +++ b/PushAndPull/PushAndPull/PushAndPull.csproj @@ -21,7 +21,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 702494ea952702eb68ba96096912709a166b8e92 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 19:48:59 +0900 Subject: [PATCH 02/13] =?UTF-8?q?update:=20Claude=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20hooks=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/hooks/commit-msg.sh | 61 ------------------------ PushAndPull/.claude/hooks/postToolUse.sh | 49 +++++++++++++++++++ PushAndPull/.claude/hooks/preCommit.sh | 50 ++++++++++++------- PushAndPull/.claude/hooks/preToolUse.sh | 51 +++++++++++--------- PushAndPull/.claude/settings.json | 15 ------ PushAndPull/.claude/settings.local.json | 32 +++++++++++++ 6 files changed, 142 insertions(+), 116 deletions(-) delete mode 100644 PushAndPull/.claude/hooks/commit-msg.sh create mode 100644 PushAndPull/.claude/hooks/postToolUse.sh delete mode 100644 PushAndPull/.claude/settings.json create mode 100644 PushAndPull/.claude/settings.local.json diff --git a/PushAndPull/.claude/hooks/commit-msg.sh b/PushAndPull/.claude/hooks/commit-msg.sh deleted file mode 100644 index 75b5ee2..0000000 --- a/PushAndPull/.claude/hooks/commit-msg.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# .claude/hooks/commit-msg.sh -# Validate commit message format for git commit commands (Claude Code PreToolUse hook) - -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -# Only handle Bash tool -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi - -# Only handle git commit commands -if [[ ! "$COMMAND" =~ git[[:space:]]+commit ]]; then - exit 0 -fi - -# Extract commit message from -m "..." or -m '...' -COMMIT_MSG=$(echo "$COMMAND" | grep -oP '(?<=-m )["\x27]?\K[^"'\'']+') - -if [[ -z "$COMMIT_MSG" ]]; then - exit 0 -fi - -# Trim whitespace -COMMIT_MSG=$(echo "$COMMIT_MSG" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - -TYPES="feat|fix|update|docs|refactor|test|chore" -PATTERN="^($TYPES): .+" - -if [[ ! "$COMMIT_MSG" =~ $PATTERN ]]; then - echo "[Hook] ✗ Invalid commit message format" - echo "" - echo "Expected: {type}: {Korean description}" - echo "Allowed types: feat, fix, update, docs, refactor, test, chore" - echo "" - echo "Examples:" - echo " feat: 방 생성 API 추가" - echo " fix: 세션 DI 누락 수정" - exit 2 -fi - -if [[ "$COMMIT_MSG" =~ [\.\!]$ ]]; then - echo "[Hook] ✗ Do not end the message with punctuation" - exit 2 -fi - -LENGTH=${#COMMIT_MSG} -if (( LENGTH < 10 )); then - echo "[Hook] ✗ Commit message too short (min: 10 chars)" - exit 2 -fi - -if (( LENGTH > 72 )); then - echo "[Hook] ✗ Commit message too long (max: 72 chars)" - exit 2 -fi - -echo "[Hook] ✓ Commit message valid" -exit 0 diff --git a/PushAndPull/.claude/hooks/postToolUse.sh b/PushAndPull/.claude/hooks/postToolUse.sh new file mode 100644 index 0000000..47ca941 --- /dev/null +++ b/PushAndPull/.claude/hooks/postToolUse.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# .claude/hooks/postToolUse.sh + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then + exit 0 +fi + +if [[ "$FILE_PATH" != *.cs ]]; then + exit 0 +fi + +echo "[Hook] C# file modified: $FILE_PATH" >&2 + +dotnet format --no-restore 2>/dev/null || echo "[Hook] format failed (ignored)" >&2 + +CACHE_FILE=".claude/.last_build_hash" + +CURRENT_HASH=$(find . -name "*.cs" -not -path "*/obj/*" | sort | xargs md5sum 2>/dev/null | md5sum | cut -d' ' -f1) + +LAST_HASH="" +if [[ -f "$CACHE_FILE" ]]; then + LAST_HASH=$(cat "$CACHE_FILE") +fi + +if [[ "$CURRENT_HASH" != "$LAST_HASH" ]]; then + echo "[Hook] Running dotnet build..." >&2 + if dotnet build PushAndPull/PushAndPull.csproj --no-restore; then + echo "$CURRENT_HASH" > "$CACHE_FILE" + else + echo "[Hook] Build failed" >&2 + exit 2 + fi +else + echo "[Hook] Skip build (no source changes)" >&2 +fi + +echo "[Hook] Running tests..." >&2 +if dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build --verbosity minimal; then + echo "[Hook] Tests passed" >&2 +else + echo "[Hook] Tests failed" >&2 + exit 2 +fi + +exit 0 diff --git a/PushAndPull/.claude/hooks/preCommit.sh b/PushAndPull/.claude/hooks/preCommit.sh index 80605df..176e9ee 100644 --- a/PushAndPull/.claude/hooks/preCommit.sh +++ b/PushAndPull/.claude/hooks/preCommit.sh @@ -1,28 +1,42 @@ #!/bin/bash # .claude/hooks/preCommit.sh -# Ensure tests pass before allowing dotnet build/run/publish (Claude Code PreToolUse hook) -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +COMMIT_MSG="$TOOL_PARAMS_MESSAGE" -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi +# allowed types +PATTERN="^(feat|fix|update|docs): .+" -if [[ ! "$COMMAND" =~ dotnet[[:space:]]+(build|run|publish) ]]; then - exit 0 +if [[ ! "$COMMIT_MSG" =~ $PATTERN ]]; then + echo "[Hook] ✗ Invalid commit message format" + echo "" + echo "Expected:" + echo " {type}: {Korean description}" + echo "" + echo "Types:" + echo " feat — new feature" + echo " fix — bug fix or missing DI/config" + echo " update — modification to existing code" + echo " docs — documentation changes" + echo "" + echo "Examples:" + echo " feat: 방 생성 API 추가" + echo " fix: 세션 DI 누락 수정" + echo " update: Room 엔터티 수정" + exit 1 fi -echo "[Hook] Checking tests before proceeding..." - -dotnet test PushAndPull/PushAndPull.sln --nologo --no-build 2>/dev/null -RESULT=$? +# punctuation check +if [[ "$COMMIT_MSG" =~ [\.\!]$ ]]; then + echo "[Hook] ✗ Do not end the message with punctuation" + echo "Example: feat: 방 생성 API 추가" + exit 1 +fi -if [ $RESULT -ne 0 ]; then - echo "[Hook] ✗ Tests failed — fix tests before running dotnet $BASH_REMATCH[1]" - exit 2 +# ensure single line +if [[ "$COMMIT_MSG" == *$'\n'* ]]; then + echo "[Hook] ✗ Commit body is not allowed" + echo "Use subject line only" + exit 1 fi -echo "[Hook] ✓ Tests passed" -exit 0 +echo "[Hook] ✓ Commit message format valid" diff --git a/PushAndPull/.claude/hooks/preToolUse.sh b/PushAndPull/.claude/hooks/preToolUse.sh index be5993b..1f34294 100644 --- a/PushAndPull/.claude/hooks/preToolUse.sh +++ b/PushAndPull/.claude/hooks/preToolUse.sh @@ -1,39 +1,46 @@ #!/bin/bash -# .claude/hooks/preToolUser.sh +# .claude/hooks/preToolUse.sh # Block dangerous commands before execution (Claude Code PreToolUse hook) INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi +[[ "$TOOL_NAME" != "Bash" ]] && exit 0 -NORMALIZED=$(echo "$COMMAND" | tr -s ' ') +NORMALIZED=$(echo "$COMMAND" | tr -s ' ' | tr -d '\n') -BLOCKED_PATTERNS=( - "rm -rf /" - "rm -rf \." - "rm -rf ~" - "rm -rf \*" - "sudo .*rm" - "> /dev/" - "dd if=" +DANGEROUS_KEYWORDS=( + "rm -rf" "mkfs" - "curl .*\| .*sh" - "curl .*\| .*bash" - "wget .*\| .*sh" - "wget .*\| .*bash" + "dd if=" + "shutdown" + "reboot" + "kill -9 -1" + ":(){" + "> /dev/sda" + "chmod -R 777 /" + "chown -R root" ) -for pattern in "${BLOCKED_PATTERNS[@]}"; do - if [[ "$NORMALIZED" =~ $pattern ]]; then - echo "[Hook] ✗ Blocked dangerous command" - echo "Command: $COMMAND" - echo "Reason: matched pattern '$pattern'" +for keyword in "${DANGEROUS_KEYWORDS[@]}"; do + if [[ "$NORMALIZED" == *"$keyword"* ]]; then + echo "[Hook] ✗ Blocked dangerous keyword: $keyword" >&2 + echo "Command: $COMMAND" >&2 exit 2 fi done +# rm -rf / or rm -rf * +if [[ "$NORMALIZED" =~ rm[[:space:]]+-rf.*(/|\*) ]]; then + echo "[Hook] ✗ Blocked root/wildcard deletion" >&2 + exit 2 +fi + +# curl/wget → pipe/redirect → sh/bash +if [[ "$NORMALIZED" =~ (curl|wget).*(\||>).*(sh|bash) ]]; then + echo "[Hook] ✗ Blocked remote execution" >&2 + exit 2 +fi + exit 0 diff --git a/PushAndPull/.claude/settings.json b/PushAndPull/.claude/settings.json deleted file mode 100644 index fed87b0..0000000 --- a/PushAndPull/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "bash .claude/hooks/preToolUse.sh" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/PushAndPull/.claude/settings.local.json b/PushAndPull/.claude/settings.local.json new file mode 100644 index 0000000..f50f1d1 --- /dev/null +++ b/PushAndPull/.claude/settings.local.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(claude mcp:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__query-docs", + "Bash(dotnet --version)", + "Bash(dotnet restore:*)", + "Bash(dotnet build:*)", + "Bash(dotnet test:*)", + "Bash(dotnet ef migrations:*)", + "Bash(dotnet ef database:*)", + "Bash(dotnet format:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git tag:*)", + "Bash(gh pr create:*)", + "Bash(rm PR_BODY.md)", + "Skill(build)", + "Skill(test)", + "Skill(commit)", + "Skill(db-migrate)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} From 19e17ff3d27fd5d02baab37fdd8ec4abb7a78e17 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 19:50:15 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20CLAUDE.md=20=EB=B0=8F=20rules=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/rules/architecture.md | 59 ++++ PushAndPull/.claude/rules/code-style.md | 106 ++++++++ PushAndPull/.claude/rules/conventions.md | 92 +++++++ PushAndPull/.claude/rules/domain-patterns.md | 97 +++++++ PushAndPull/.claude/rules/flows.md | 85 ++++++ PushAndPull/.claude/rules/global-patterns.md | 68 +++++ PushAndPull/.claude/rules/testing.md | 77 ++++++ PushAndPull/.claude/rules/verify.md | 17 ++ PushAndPull/CLAUDE.md | 270 +++---------------- 9 files changed, 640 insertions(+), 231 deletions(-) create mode 100644 PushAndPull/.claude/rules/architecture.md create mode 100644 PushAndPull/.claude/rules/code-style.md create mode 100644 PushAndPull/.claude/rules/conventions.md create mode 100644 PushAndPull/.claude/rules/domain-patterns.md create mode 100644 PushAndPull/.claude/rules/flows.md create mode 100644 PushAndPull/.claude/rules/global-patterns.md create mode 100644 PushAndPull/.claude/rules/testing.md create mode 100644 PushAndPull/.claude/rules/verify.md diff --git a/PushAndPull/.claude/rules/architecture.md b/PushAndPull/.claude/rules/architecture.md new file mode 100644 index 0000000..6cbaf87 --- /dev/null +++ b/PushAndPull/.claude/rules/architecture.md @@ -0,0 +1,59 @@ +--- +description: Overall architecture, directory structure, and layering rules. Always applied. +globs: +alwaysApply: true +--- + +## Directory Structure + +``` +PushAndPull/ +├── Domain/ +│ ├── {DomainName}/ +│ │ ├── Config/ # DI registration extension methods +│ │ ├── Controller/ # ASP.NET Core controllers +│ │ ├── Dto/ +│ │ │ ├── Request/ +│ │ │ └── Response/ +│ │ ├── Entity/ +│ │ │ └── Config/ # EF Core Fluent API configurations +│ │ ├── Exception/ # Domain-specific exceptions +│ │ ├── Repository/ +│ │ │ └── Interface/ +│ │ └── Service/ +│ │ └── Interface/ # Service interface + Command/Result records +└── Global/ + ├── Auth/ # Steam ticket validation (IAuthTicketValidator) + ├── Cache/ # Redis cache (ICacheStore, CacheKey) + ├── Config/ # Infrastructure registrations (DB, Redis) + ├── Infrastructure/ # AppDbContext + ├── Security/ # [SessionAuthorize], ClaimsPrincipalExtensions + └── Service/ # Shared utilities (IPasswordHasher, IRoomCodeGenerator) +``` + +## Layering Rules + +- **Controllers** depend on service interfaces only — never concrete services or repositories. +- **Services** depend on repository interfaces and global service interfaces only. +- **Repositories** are the only layer that touches `AppDbContext`. +- Interfaces (repository + service) are co-located within their domain folder. + +Forbidden: +``` +Controller → Repository ❌ +Controller → concrete class ❌ +Service → concrete Repository ❌ +Entity → Service / Repository ❌ +``` + +## Adding a New Domain + +1. Define entity in `Domain/{Name}/Entity/` +2. Add EF Fluent config in `Domain/{Name}/Entity/Config/` +3. Register `DbSet` in `AppDbContext` +4. Define DTOs (records) in `Domain/{Name}/Dto/Request/` and `Dto/Response/` +5. Define repository interface in `Domain/{Name}/Repository/Interface/` +6. Define service interface(s) + Command/Result records in `Domain/{Name}/Service/Interface/` +7. Implement repository and service +8. Create `Domain/{Name}/Config/{Name}ServiceConfig.cs` with DI extension method +9. Call the extension method from `Program.cs` diff --git a/PushAndPull/.claude/rules/code-style.md b/PushAndPull/.claude/rules/code-style.md new file mode 100644 index 0000000..8eae99c --- /dev/null +++ b/PushAndPull/.claude/rules/code-style.md @@ -0,0 +1,106 @@ +--- +description: C# code style rules. Always applied for all .cs files. +alwaysApply: true +--- + +## C# Code Style + +### General + +- Use `var` only when the type is obvious from the right-hand side. +- Prefer expression-body methods for single-line implementations. +- No XML doc comments unless explicitly requested. +- No `#region` blocks. + +### Naming + +- Private fields: `_camelCase` (underscore prefix) +- Properties, methods, classes: `PascalCase` +- Local variables and parameters: `camelCase` +- Interfaces: `IPascalCase` +- Database column names: `snake_case` + +### Classes & Constructors + +- Constructor-inject all dependencies; assign to `private readonly` fields. +- Keep constructors minimal — no logic, only assignments. + +```csharp +// Good +public class LoginService : ILoginService +{ + private readonly IAuthTicketValidator _validator; + private readonly ISessionService _sessionService; + + public LoginService(IAuthTicketValidator validator, ISessionService sessionService) + { + _validator = validator; + _sessionService = sessionService; + } +} +``` + +### DTOs + +- Always use `record` types. +- Request DTOs in `Dto/Request/`, Response DTOs in `Dto/Response/`. + +```csharp +public record CreateRoomRequest( + long LobbyId, + string RoomName, + bool IsPrivate, + string? Password +); +``` + +### Entities + +- Default constructor: `protected` or `private`. +- Public constructor accepts required fields only. +- State changes only through domain methods (`room.Join()`, `user.UpdateNickname()`). +- No direct field mutation from outside the entity. + +```csharp +public class Room +{ + protected Room() { } + + public Room(string roomCode, string roomName, ulong hostSteamId) + { + RoomCode = roomCode; + RoomName = roomName; + HostSteamId = hostSteamId; + } + + public void Join(ulong steamId) { /* domain logic */ } +} +``` + +### Service Command/Result Pattern + +- Each service interface defines its own `Command` (input) and `Result` (output) records. +- Co-located in the same Interface file. + +```csharp +public interface ILoginService +{ + Task ExecuteAsync(LoginCommand command, CancellationToken ct = default); +} + +public record LoginCommand(string Ticket, string Nickname); +public record LoginResult(string SessionId); +``` + +### Async + +- All I/O methods are `async Task` or `async Task`. +- Never use `.Result` or `.Wait()`. +- Always `await` — no fire-and-forget unless intentional. +- Pass `CancellationToken` through to repository/async calls where appropriate. + +### Exception Handling + +- Throw domain-specific exceptions (e.g., `NotFoundException`, `UnauthorizedException` from `Gamism.SDK`). +- Do not catch and re-throw unless adding context. +- No empty catch blocks. diff --git a/PushAndPull/.claude/rules/conventions.md b/PushAndPull/.claude/rules/conventions.md new file mode 100644 index 0000000..01bb9a1 --- /dev/null +++ b/PushAndPull/.claude/rules/conventions.md @@ -0,0 +1,92 @@ +--- +description: Naming, DTO, EF Core entity configuration, and cache conventions. Always applied. +alwaysApply: true +--- + +## Naming Conventions + +| Type | Convention | Example | +|------|-----------|---------| +| Service Interface | `I{Action}Service` | `ILoginService` | +| Repository Interface | `I{Entity}Repository` | `IUserRepository` | +| Command | `{Action}Command` | `LoginCommand` | +| Result | `{Action}Result` | `LoginResult` | +| Request DTO | `{Action}Request` | `CreateRoomRequest` | +| Response DTO | `{Action}Response` | `CreateRoomResponse` | +| Service impl | `{Action}Service` | `LoginService` | + +- Database column names: `snake_case` +- Timestamps: `timestamptz` +- Enums stored as strings: `.HasConversion()` + +## DTOs + +- Use `record` types only — `Request/` for HTTP input, `Response/` for HTTP output. +- No DataAnnotations on entities — only on Request DTOs if needed. + +## Entity Configuration (EF Core) + +- Use `IEntityTypeConfiguration` Fluent API — **never DataAnnotations on entities**. +- Place in `Domain/{Name}/Entity/Config/`. +- `AppDbContext.OnModelCreating` contains only: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); +} +``` + +Example config: +```csharp +public class RoomConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("room"); + builder.HasKey(e => e.Id); + builder.Property(e => e.RoomCode).HasColumnName("room_code"); + builder.Property(e => e.Status) + .HasColumnName("status") + .HasConversion(); + builder.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .HasColumnType("timestamptz"); + } +} +``` + +## Cache (Redis) + +All Redis keys must go through `CacheKey` — hardcoded strings are **forbidden**. + +```csharp +// Correct +CacheKey.Session.ById(sessionId) + +// Forbidden +"session:" + sessionId +``` + +## Domain DI Registration + +Each domain exposes a single static extension method in `Config/`: + +```csharp +public static class RoomServiceConfig +{ + public static IServiceCollection AddRoomServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} +``` + +Call all domain configs from `Program.cs`. **Never skip DI registration after adding a new service.** + +## Read Queries + +Always use `AsNoTracking()` for queries that do not modify entities. diff --git a/PushAndPull/.claude/rules/domain-patterns.md b/PushAndPull/.claude/rules/domain-patterns.md new file mode 100644 index 0000000..d7b692b --- /dev/null +++ b/PushAndPull/.claude/rules/domain-patterns.md @@ -0,0 +1,97 @@ +--- +description: Domain layer patterns (Service, Repository, Controller). Applied when working on Domain/** files. +globs: ["PushAndPull/Domain/**"] +alwaysApply: false +--- + +## Service Pattern + +Each use case is a single class implementing a single interface with one `ExecuteAsync` method. +`Command` (input) and `Result` (output) records are defined in the same Interface file. + +```csharp +// Interface file: Domain/Room/Service/Interface/ICreateRoomService.cs +public interface ICreateRoomService +{ + Task ExecuteAsync(CreateRoomCommand command, CancellationToken ct = default); +} + +public record CreateRoomCommand(ulong HostSteamId, long LobbyId, string RoomName, bool IsPrivate, string? Password); +public record CreateRoomResult(string RoomCode); + +// Implementation: Domain/Room/Service/CreateRoomService.cs +public class CreateRoomService : ICreateRoomService +{ + private readonly IRoomRepository _roomRepository; + private readonly IRoomCodeGenerator _codeGenerator; + + public CreateRoomService(IRoomRepository roomRepository, IRoomCodeGenerator codeGenerator) + { + _roomRepository = roomRepository; + _codeGenerator = codeGenerator; + } + + public async Task ExecuteAsync(CreateRoomCommand command, CancellationToken ct = default) + { + var code = _codeGenerator.Generate(); + var room = new Room(code, command.RoomName, command.HostSteamId); + await _roomRepository.AddAsync(room, ct); + return new CreateRoomResult(code); + } +} +``` + +## Repository Pattern + +- Interface in `Repository/Interface/`, implementation in `Repository/`. +- Only repositories access `AppDbContext`. +- Use `AsNoTracking()` for read-only queries. + +```csharp +public class RoomRepository : IRoomRepository +{ + private readonly AppDbContext _db; + + public RoomRepository(AppDbContext db) => _db = db; + + public async Task GetByCodeAsync(string roomCode, CancellationToken ct = default) + => await _db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.RoomCode == roomCode, ct); + + public async Task AddAsync(Room room, CancellationToken ct = default) + { + await _db.Rooms.AddAsync(room, ct); + await _db.SaveChangesAsync(ct); + } +} +``` + +## Controller Pattern + +- Inject service interfaces only. +- Map `Request` → `Command`, call service, map `Result` → `Response`. +- Apply `[SessionAuthorize]` on authenticated endpoints. +- Use `User.GetSteamId()` / `User.GetSessionId()` extension methods for claims. + +```csharp +[ApiController] +[Route("api/v1/room")] +public class RoomController : ControllerBase +{ + private readonly ICreateRoomService _createRoomService; + + public RoomController(ICreateRoomService createRoomService) + { + _createRoomService = createRoomService; + } + + [SessionAuthorize] + [HttpPost] + public async Task> CreateRoom([FromBody] CreateRoomRequest request) + { + var steamId = User.GetSteamId(); + var result = await _createRoomService.ExecuteAsync( + new CreateRoomCommand(steamId, request.LobbyId, request.RoomName, request.IsPrivate, request.Password)); + return CommonApiResponse.Created("Room created.", new CreateRoomResponse(result.RoomCode)); + } +} +``` diff --git a/PushAndPull/.claude/rules/flows.md b/PushAndPull/.claude/rules/flows.md new file mode 100644 index 0000000..0f893c5 --- /dev/null +++ b/PushAndPull/.claude/rules/flows.md @@ -0,0 +1,85 @@ +## API Flow Diagrams + +### POST /api/v1/auth/login — Steam 로그인 + +```mermaid +sequenceDiagram + Client->>AuthController: POST /api/v1/auth/login {ticket, nickname} + AuthController->>LoginService: ExecuteAsync(LoginCommand) + LoginService->>IAuthTicketValidator: ValidateAsync(ticket) + IAuthTicketValidator->>SteamAPI: AuthenticateUserTicket + SteamAPI-->>IAuthTicketValidator: steamId + IAuthTicketValidator-->>LoginService: AuthTicketValidationResult + LoginService->>IUserRepository: GetBySteamIdAsync(steamId) + IUserRepository-->>LoginService: null → CreateAsync(user) + IUserRepository-->>LoginService: user → UpdateAsync(nickname) + LoginService->>ISessionService: CreateAsync(steamId, ttl) + ISessionService->>Redis: SET session:{id} steamId EX + ISessionService-->>LoginService: PlayerSession + AuthController-->>Client: 200 {sessionId} +``` + +### POST /api/v1/auth/logout — 로그아웃 + +```mermaid +sequenceDiagram + Client->>AuthController: POST /api/v1/auth/logout (Session-Id) + Note over AuthController: [SessionAuthorize] + AuthController->>LogoutService: ExecuteAsync(LogoutCommand) + LogoutService->>ISessionService: DeleteAsync(sessionId) + ISessionService->>Redis: DEL session:{id} + AuthController-->>Client: 200 +``` + +### POST /api/v1/room — 방 생성 + +```mermaid +sequenceDiagram + Client->>RoomController: POST /api/v1/room {lobbyId, roomName, ...} + Note over RoomController: [SessionAuthorize] + RoomController->>CreateRoomService: ExecuteAsync(CreateRoomCommand) + CreateRoomService->>IRoomCodeGenerator: Generate() + CreateRoomService->>IRoomRepository: AddAsync(room) + IRoomRepository->>PostgreSQL: INSERT room + RoomController-->>Client: 201 {roomCode} +``` + +### GET /api/v1/room/all — 방 목록 조회 + +```mermaid +sequenceDiagram + Client->>RoomController: GET /api/v1/room/all + RoomController->>GetAllRoomService: ExecuteAsync() + GetAllRoomService->>IRoomRepository: GetAllActiveAsync() + IRoomRepository->>PostgreSQL: SELECT rooms (AsNoTracking) + IRoomRepository-->>GetAllRoomService: rooms + RoomController-->>Client: 200 [roomList] +``` + +### GET /api/v1/room/{roomCode} — 방 상세 조회 + +```mermaid +sequenceDiagram + Client->>RoomController: GET /api/v1/room/{roomCode} + RoomController->>GetRoomService: ExecuteAsync(GetRoomCommand) + GetRoomService->>IRoomRepository: GetByCodeAsync(roomCode) + IRoomRepository->>PostgreSQL: SELECT room (AsNoTracking) + IRoomRepository-->>GetRoomService: null → NotFoundException + IRoomRepository-->>GetRoomService: room + RoomController-->>Client: 200 {roomDetail} +``` + +### POST /api/v1/room/{roomCode}/join — 방 참여 + +```mermaid +sequenceDiagram + Client->>RoomController: POST /api/v1/room/{roomCode}/join + Note over RoomController: [SessionAuthorize] + RoomController->>JoinRoomService: ExecuteAsync(JoinRoomCommand) + JoinRoomService->>IRoomRepository: GetByCodeAsync(roomCode) + IRoomRepository-->>JoinRoomService: null → NotFoundException + JoinRoomService->>Room: Join(steamId) + JoinRoomService->>IRoomRepository: UpdateAsync(room) + IRoomRepository->>PostgreSQL: UPDATE room + RoomController-->>Client: 200 {lobbyId} +``` diff --git a/PushAndPull/.claude/rules/global-patterns.md b/PushAndPull/.claude/rules/global-patterns.md new file mode 100644 index 0000000..115f752 --- /dev/null +++ b/PushAndPull/.claude/rules/global-patterns.md @@ -0,0 +1,68 @@ +--- +description: Global infrastructure patterns (Steam Auth, Redis Session, CacheKey, Security). Applied when working on Global/** files. +globs: ["PushAndPull/Global/**"] +alwaysApply: false +--- + +## Global Config Extension Methods + +All infrastructure registrations are `IServiceCollection` extension methods in `Global/Config/`: + +| Class | Method | Purpose | +|---|---|---| +| `DatabaseConfig` | `AddDatabase` | EF Core + PostgreSQL (Npgsql) | +| `RedisConfig` | `AddRedis` | StackExchange.Redis + IDistributedCache | + +## Steam Authentication + +Steam ticket validation is handled by `IAuthTicketValidator` in `Global/Auth/`: + +- `ValidateAsync(ticket)` — calls `ISteamUserAuth/AuthenticateUserTicket/v1` and returns `AuthTicketValidationResult` +- Returns `steamId` (`ulong`) on success +- Throws `UnauthorizedException` on invalid/expired ticket + +## Session Store (Redis) + +Sessions are stored in Redis via `ISessionService` / `ISessionStore`: + +- Session key: `CacheKey.Session.ById(sessionId)` +- Session contains `SteamId` (`ulong`) +- TTL: 15 days (configurable) + +**All Redis keys must use `CacheKey` — no hardcoded strings:** + +```csharp +// Correct +CacheKey.Session.ById(sessionId) + +// Forbidden +"session:" + sessionId +``` + +## Security + +`[SessionAuthorize]` attribute validates `Session-Id` header on each request: +- Reads session from Redis +- Populates `ClaimsPrincipal` with `SteamId` and `SessionId` + +Extension methods for claims extraction: + +```csharp +// In controller (after [SessionAuthorize]) +ulong steamId = User.GetSteamId(); +string sessionId = User.GetSessionId(); +``` + +**SteamId type rule:** +- Entity: `ulong` +- Claims: `long` (stored as long in JWT/claims) +- Always convert when crossing the boundary + +## Shared Global Services + +Registered via `GlobalServiceConfig` in `Global/Config/`: + +- `IPasswordHasher` — BCrypt wrapper +- `IRoomCodeGenerator` — generates unique room codes +- `IAuthTicketValidator` — Steam ticket validator +- `ISessionService` — Redis session management diff --git a/PushAndPull/.claude/rules/testing.md b/PushAndPull/.claude/rules/testing.md new file mode 100644 index 0000000..f25a539 --- /dev/null +++ b/PushAndPull/.claude/rules/testing.md @@ -0,0 +1,77 @@ +--- +description: xUnit + Moq test conventions. Applied when working on PushAndPull.Test/** files. +globs: ["PushAndPull.Test/**"] +alwaysApply: false +--- + +## Test Project Structure + +``` +PushAndPull.Test/ +├── Domain/ +│ └── {DomainName}/ # Entity unit tests +└── Service/ + └── {DomainName}/ # Service unit tests +``` + +## Conventions + +- Test class name: `{ServiceName}Tests` — e.g., `LoginServiceTests` +- Nested class per scenario (English): `WhenANewUserLogsIn`, `WhenTheRoomIsFull` +- Test method name: `It_{ExpectedResult}` — e.g., `It_CreatesANewUser`, `It_ThrowsNotFoundException` +- Use `Moq` for mocking — mock repository interfaces, not `AppDbContext` directly. +- Constructor for mock setup; each test method for assertion + verification. + +```csharp +public class LoginServiceTests +{ + public class WhenANewUserLogsInForTheFirstTime + { + private readonly Mock _validatorMock = new(); + private readonly Mock _sessionServiceMock = new(); + private readonly Mock _userRepositoryMock = new(); + private readonly LoginService _sut; + + private const string Ticket = "valid-ticket"; + 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); + + _sut = new LoginService(_validatorMock.Object, _sessionServiceMock.Object, _userRepositoryMock.Object); + } + + [Fact] + public async Task It_CreatesANewUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, "Player")); + + _userRepositoryMock.Verify(r => r.CreateAsync( + It.Is(u => u.SteamId == SteamId), + CancellationToken.None), Times.Once); + } + + [Fact] + public async Task It_DoesNotCallUpdateUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, "Player")); + + _userRepositoryMock.Verify(r => r.UpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None), Times.Never); + } + } +} +``` + +## Arrange / Act / Assert + +- Separate Arrange (constructor), Act, Assert with blank lines. +- Use `xunit` built-in `Assert` — no FluentAssertions in this project. +- Verify both positive behavior (`Times.Once`) and negative behavior (`Times.Never`). diff --git a/PushAndPull/.claude/rules/verify.md b/PushAndPull/.claude/rules/verify.md new file mode 100644 index 0000000..255c467 --- /dev/null +++ b/PushAndPull/.claude/rules/verify.md @@ -0,0 +1,17 @@ +--- +description: Build-and-verify workflow. Run build then tests after any C# code change. +globs: ["**/*.cs"] +alwaysApply: false +--- + +## Build-and-Verify Workflow + +After adding or modifying any `.cs` file, run `/test` without asking the user. + +**If the build fails:** fix the build errors, then run `/test` again. + +**If tests fail:** +- Production code bug → fix the production code. +- Test is outdated or needs updating to match new behavior → fix the test code. + +Run `/test` again after fixing. Only consider the task complete when the build succeeds and all tests pass. diff --git a/PushAndPull/CLAUDE.md b/PushAndPull/CLAUDE.md index 3d31d97..0cf2886 100644 --- a/PushAndPull/CLAUDE.md +++ b/PushAndPull/CLAUDE.md @@ -1,255 +1,63 @@ -# CLAUDE.md — Push & Pull Server +# Push & Pull Server — Claude Code Guide **Always respond in Korean.** **All skill files, command files, hooks, and sub-agent configuration files must be written in English.** ---- +## Solution Overview -# Project Overview +.NET 9 single-solution backend: -Steam-based multiplayer matchmaking / lobby server. - -Players authenticate via **Steam tickets**, create or join rooms, and exchange **Steam Lobby IDs**. Actual gameplay runs over **Steam P2P** — this server only handles session auth, room management, and discovery. +| Project | Type | Role | +|---|---|---| +| `PushAndPull` | ASP.NET Core Web API | Steam auth, room management, session handling | +| `PushAndPull.Test` | xUnit Test Project | Unit tests for services and entities | | Item | Value | -|------|-------| -| Framework | net9.0 | +|---|---| | Runtime | Linux container, port 8080 | -| Database | PostgreSQL (EF Core 9) | +| Database | PostgreSQL (EF Core 9 + Npgsql) | | Cache | Redis (session store) | | Auth | Steam ticket (`Session-Id` header, no Bearer) | ---- - -# Quick Start (Read This First) - -If you're new to this project, read in this order: - -1. Architecture -2. Layer Responsibilities -3. Common Mistakes -4. Coding Conventions - -Key rules: - -- Controller → Service Interface only -- Service → Repository Interface only -- No direct Entity field mutation — use domain methods -- Always `AsNoTracking()` for read queries -- Always `CacheKey` for Redis keys — no hardcoded strings -- EF mapping in `IEntityTypeConfiguration` only — no DataAnnotations -- Register new services in `Domain/{Domain}/Config/` after adding them - ---- - -# Commands - -```bash -# Build -dotnet build PushAndPull/PushAndPull.csproj - -# Run locally -dotnet run --project PushAndPull/PushAndPull.csproj - -# Add EF migration -dotnet ef migrations add --project PushAndPull - -# Apply migration -dotnet ef database update --project PushAndPull - -# Docker -docker build -f PushAndPull/Dockerfile -t pushandpull-server . -docker run -p 8080:8080 pushandpull-server -``` - ---- - -# Architecture +## Tech Stack -Domain-centric layered architecture. Each domain (`Auth`, `Room`) is self-contained. Cross-cutting concerns live under `Global/`. +| Package | Version | Context7 ID | +|---|---|---| +| .NET / ASP.NET Core | 9.0 | `/dotnet/docs` | +| `Microsoft.EntityFrameworkCore` | 9.0.12 | `/dotnet/docs` | +| `Npgsql.EntityFrameworkCore.PostgreSQL` | 9.0.4 | `/npgsql/efcore.pg` | +| `Microsoft.Extensions.Caching.StackExchangeRedis` | 9.0.3 | `/stackexchange/stackexchange.redis` | +| `Dapper` | 2.1.66 | `/dotnet/docs` | +| `BCrypt.Net-Next` | 4.0.3 | — (no Context7 entry) | +| `Gamism.SDK.Extensions.AspNetCore` | 0.2.8 | — (no Context7 entry) | +| `xunit` | 2.9.2 | `/xunit/xunit.net` | +| `Moq` | 4.20.72 | — (no Context7 entry) | -## Dependency Rules +## Context7 Usage -``` -Controller → Service Interface -Service → Repository Interface, Global Service Interface -Repository → AppDbContext -``` +When working with any library listed above, use the Context7 MCP to fetch version-accurate official documentation before writing or modifying code. -Forbidden: - -``` -Controller → Repository ❌ -Controller → concrete class ❌ -Service → concrete Repository ❌ -Entity → Service / Repository ❌ ``` +# Example: EF Core Fluent API +mcp__context7__query-docs(libraryId: "/dotnet/docs", query: "EF Core IEntityTypeConfiguration fluent API", version: "9.0") -## Key Paths - -| Layer | Path | -|-------|------| -| Controller | `Domain/{Domain}/Controller/` | -| Service Interface + Command/Result | `Domain/{Domain}/Service/Interface/` | -| Service | `Domain/{Domain}/Service/` | -| Repository Interface | `Domain/{Domain}/Repository/Interface/` | -| Repository | `Domain/{Domain}/Repository/` | -| Entity | `Domain/{Domain}/Entity/` | -| Entity EF Config | `Domain/{Domain}/Entity/Config/` | -| DTO (Request/Response) | `Domain/{Domain}/Dto/Request/`, `Dto/Response/` | -| Exception | `Domain/{Domain}/Exception/` | -| DI Registration | `Domain/{Domain}/Config/` | - -## Global/ - -| Folder | Role | -|--------|------| -| `Auth/` | Steam ticket validation (`IAuthTicketValidator`) | -| `Cache/` | Redis cache (`ICacheStore`, `CacheKey`) | -| `Config/` | Global DI, DB/Redis config | -| `Infrastructure/` | `AppDbContext` | -| `Security/` | `[SessionAuthorize]`, `ClaimsPrincipalExtensions` | -| `Service/` | Shared utilities (`IPasswordHasher`, `IRoomCodeGenerator`) | - ---- - -# Layer Responsibilities - -### Controller -- Maps `Request` → `Command`, calls service, maps `Result` → `Response` -- Applies `[SessionAuthorize]` on authenticated endpoints -- Depends **only on Service Interfaces** - -### Service -- Implements business logic via `ExecuteAsync(...)` -- `Command` (input) and `Result` (output) records defined in the same Interface file -- Depends only on Repository/Global Interfaces - -### Repository -- Abstracts data access (EF Core or Dapper) -- **Read queries must use `AsNoTracking()`** - -### Entity -- Pure domain model, no external dependencies -- State changes only through methods (`room.Join()`, `user.UpdateNickname()`) -- Default constructor: `protected`/`private`; public constructor accepts required fields only - -### DTO -- `record` type only -- `Request/` for HTTP input, `Response/` for HTTP output - ---- - -# Coding Conventions +# Example: Npgsql EF Core setup +mcp__context7__query-docs(libraryId: "/npgsql/efcore.pg", query: "UseNpgsql configuration") -## Naming - -| Type | Convention | Example | -|------|-----------|---------| -| Service Interface | `I{Action}Service` | `ILoginService` | -| Repository Interface | `I{Entity}Repository` | `IUserRepository` | -| Command | `{Action}Command` | `LoginCommand` | -| Result | `{Action}Result` | `LoginResult` | -| Request DTO | `{Action}Request` | `LoginRequest` | -| Response DTO | `{Action}Response` | `LoginResponse` | -| Service impl | `{Action}Service` | `LoginService` | - -## General Rules - -- DTOs: `record` type -- Entities / Services: `class` -- Constructor injection only -- Nullable reference types: enabled -- Implicit usings: enabled - ---- - -# Database Rules - -- Column naming: `snake_case` -- Timestamps: `timestamptz` -- Enums: `.HasConversion()` -- Read queries: always `AsNoTracking()` - -**EF mapping must use `IEntityTypeConfiguration` per entity** (`Domain/{Domain}/Entity/Config/`). DataAnnotations are forbidden. - -`AppDbContext.OnModelCreating` contains only: - -```csharp -base.OnModelCreating(modelBuilder); -modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); -``` - ---- - -# Cache Rules - -Redis is used as a **session store**. All keys must go through `CacheKey` — hardcoded strings are forbidden. - -```csharp -// Correct -CacheKey.Session.ById(sessionId) - -// Forbidden -"session:" + sessionId -``` - ---- - -# Authentication - -- Steam: `ISteamUserAuth/AuthenticateUserTicket/v1` -- Session via header: `Session-Id: ` (no Bearer tokens) -- SteamId: `ulong` in Entity, `long` in Claims - ---- - -# API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | `/api/v1/auth/login` | No | Steam ticket login | -| POST | `/api/v1/auth/logout` | Session | Logout | -| POST | `/api/v1/room` | Session | Create room | -| GET | `/api/v1/room/all` | No | List active rooms | -| GET | `/api/v1/room/{roomCode}` | No | Get room info | -| POST | `/api/v1/room/{roomCode}/join` | Session | Join room | - ---- - -# Commit Message Style - -``` -{type}: {Korean description} -``` - -| Type | When | -|------|------| -| `feat` | New feature | -| `fix` | Bug fix, missing config/DI | -| `update` | Modification to existing code | -| `docs` | Documentation changes | -| `merge` | Branch merge | -| `release` | Release (`release/x.x.x`) | - -Rules: Korean description, imperative, no trailing punctuation. - -``` -feat: 방 생성 API 추가 -fix: 세션 DI 누락 수정 -modify: Room 엔터티 수정 +# Example: Redis session +mcp__context7__query-docs(libraryId: "/stackexchange/stackexchange.redis", query: "IDistributedCache SetString GetString") ``` ---- +`BCrypt.Net-Next`, `Moq`, and `Gamism.SDK.Extensions.AspNetCore` have no Context7 entry — refer to the source code directly. -# Common Mistakes +## Reference Docs -- Missing `[SessionAuthorize]` on authenticated endpoints -- SteamId type mismatch (`ulong` vs `long`) -- Modifying Entity fields directly instead of through domain methods -- Hardcoding Redis keys instead of using `CacheKey` -- Controller calling Repository directly -- Service referencing concrete class instead of Interface -- EF mapping via DataAnnotation instead of `IEntityTypeConfiguration` -- Missing DI registration after adding a new service +- `.claude/rules/architecture.md` — directory structure and layering rules (Controllers → Services → Repositories) +- `.claude/rules/code-style.md` — C# naming conventions, entity/DTO/async/Command-Result patterns +- `.claude/rules/conventions.md` — DB naming, EF Core Fluent API config, CacheKey usage, DI registration +- `.claude/rules/domain-patterns.md` — service, repository, and controller implementation patterns +- `.claude/rules/global-patterns.md` — Steam auth, Redis session store, CacheKey, SessionAuthorize +- `.claude/rules/testing.md` — test project structure, naming conventions, Moq patterns +- `.claude/rules/flows.md` — Mermaid sequence diagrams for each API endpoint +- `.claude/rules/verify.md` — build-and-verify workflow (auto build + test after every C# code change) From 707464fca527046cba7debe3f4a2912ff256265c Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 19:50:29 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20commands=EB=A5=BC=20skills?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/commands/commit.md | 72 ----------------- PushAndPull/.claude/commands/migrate.md | 26 ------ PushAndPull/.claude/commands/test.md | 16 ---- .../build.md => skills/build/SKILL.md} | 6 +- PushAndPull/.claude/skills/commit/SKILL.md | 55 +++++++++++++ .../skills/commit/examples/type-guide.md | 78 ++++++++++++++++++ .../.claude/skills/db-migrate/SKILL.md | 79 +++++++++++++++++++ .../skills/db-migrate/examples/naming.md | 66 ++++++++++++++++ .../{commands/pr.md => skills/pr/SKILL.md} | 78 ++++++++---------- .../skills/pr/examples/feature-to-develop.md | 48 +++++++++++ .../.claude/skills/pr/templates/pr-body.md | 18 +++++ PushAndPull/.claude/skills/test/SKILL.md | 60 ++++++++++++++ .../skills/test/examples/filter-patterns.md | 70 ++++++++++++++++ 13 files changed, 513 insertions(+), 159 deletions(-) delete mode 100644 PushAndPull/.claude/commands/commit.md delete mode 100644 PushAndPull/.claude/commands/migrate.md delete mode 100644 PushAndPull/.claude/commands/test.md rename PushAndPull/.claude/{commands/build.md => skills/build/SKILL.md} (68%) create mode 100644 PushAndPull/.claude/skills/commit/SKILL.md create mode 100644 PushAndPull/.claude/skills/commit/examples/type-guide.md create mode 100644 PushAndPull/.claude/skills/db-migrate/SKILL.md create mode 100644 PushAndPull/.claude/skills/db-migrate/examples/naming.md rename PushAndPull/.claude/{commands/pr.md => skills/pr/SKILL.md} (62%) create mode 100644 PushAndPull/.claude/skills/pr/examples/feature-to-develop.md create mode 100644 PushAndPull/.claude/skills/pr/templates/pr-body.md create mode 100644 PushAndPull/.claude/skills/test/SKILL.md create mode 100644 PushAndPull/.claude/skills/test/examples/filter-patterns.md diff --git a/PushAndPull/.claude/commands/commit.md b/PushAndPull/.claude/commands/commit.md deleted file mode 100644 index 24d32b6..0000000 --- a/PushAndPull/.claude/commands/commit.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -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 -- `update` — modification to existing code -- `docs` — documentation changes - -**Description rules**: -- Written in **Korean** -- Short and imperative (단문) -- No trailing punctuation (`.`, `!` etc.) -- Avoid noun-ending style — prefer verb style - -**Examples**: -``` -feat: 방 생성 API 추가 -fix: 세션 DI 누락 수정 -update: Room 엔터티 수정 -``` - -**Do NOT**: -- Add Claude as co-author -- Write descriptions in English -- Add a commit body — subject line only - -## Splitting Rules - -**Changes must be split into logical units. Never combine unrelated changes into a single commit.** - -Splitting criteria: -- Different domains → separate commits (Auth changes / Room changes) -- Different roles → separate commits (feature addition / bug fix / refactoring) -- Multiple files with a single purpose → can be grouped into one commit - -Example — correct splitting: -``` -feat: 방 참여 API 추가 ← Room feature files -fix: 세션 DI 누락 수정 ← Auth DI registration fix -update: Room 엔터티 수정 ← Room Entity change -``` - -Example — incorrect splitting: -``` -feat: 방 참여 API 추가 및 세션 버그 수정 ← two purposes in one commit ❌ -``` - -## Steps - -1. Run `git status` and `git diff` to review all changes -2. Classify changed files into logical groups: - - New feature → `feat` - - Bug fix / missing registration → `fix` - - Modification to existing code → `update` - - Documentation changes → `docs` -3. If there are 2+ groups, they must be committed separately -4. For each group: - - Stage only the relevant files with `git add ` - - Write a commit message following the format above - - Run `git commit -m "message"` -5. Verify with `git log --oneline -n {commit count}` diff --git a/PushAndPull/.claude/commands/migrate.md b/PushAndPull/.claude/commands/migrate.md deleted file mode 100644 index ace8101..0000000 --- a/PushAndPull/.claude/commands/migrate.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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. - -Migration name must follow the convention: `{Verb}{Target}{Change}` (e.g. `AddRoomPasswordColumn`, `RemoveUserNicknameColumn`). - -Usage: `/migrate ` - -## Steps - -1. Run the migration command using the argument as the migration name: - ```bash - dotnet ef migrations add $ARGUMENTS --project PushAndPull - ``` -2. After the migration is created: - - Read the generated migration file under `PushAndPull/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 PushAndPull - ``` - - Confirm the update result when done diff --git a/PushAndPull/.claude/commands/test.md b/PushAndPull/.claude/commands/test.md deleted file mode 100644 index c0cb798..0000000 --- a/PushAndPull/.claude/commands/test.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -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/commands/build.md b/PushAndPull/.claude/skills/build/SKILL.md similarity index 68% rename from PushAndPull/.claude/commands/build.md rename to PushAndPull/.claude/skills/build/SKILL.md index 5824297..ed55a54 100644 --- a/PushAndPull/.claude/commands/build.md +++ b/PushAndPull/.claude/skills/build/SKILL.md @@ -1,6 +1,8 @@ --- -description: Build the server project and report errors -allowed-tools: Bash +name: build +description: Builds the PushAndPull project and reports errors. Use for checking build failures and debugging compile errors. +allowed-tools: Bash(dotnet build:*) +context: fork --- Build the server project and report any errors. diff --git a/PushAndPull/.claude/skills/commit/SKILL.md b/PushAndPull/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..1168fe7 --- /dev/null +++ b/PushAndPull/.claude/skills/commit/SKILL.md @@ -0,0 +1,55 @@ +--- +name: commit +description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*) +--- + +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 +- `update` — modification to existing code +- `docs` — documentation changes + +**Description rules**: +- Written in **Korean** +- Short and imperative (단문) +- No trailing punctuation (`.`, `!` etc.) +- Avoid noun-ending style — prefer verb style + +**Examples**: +``` +feat: 방 생성 API 추가 +fix: 세션 DI 누락 수정 +update: Room 엔터티 수정 +docs: API 엔드포인트 목록 업데이트 +``` + +See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and real scenarios from this project. + +**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 → `update` + - Documentation changes → `docs` +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/skills/commit/examples/type-guide.md b/PushAndPull/.claude/skills/commit/examples/type-guide.md new file mode 100644 index 0000000..ce1c73b --- /dev/null +++ b/PushAndPull/.claude/skills/commit/examples/type-guide.md @@ -0,0 +1,78 @@ +# Commit Type Guide — Push & Pull Server + +## feat — New capability added to the codebase + +Use when creating new files is the primary change. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add LoginService.cs, LogoutService.cs | `feat: 로그인·로그아웃 서비스 추가` | +| Add RoomController.cs | `feat: Room 컨트롤러 추가` | +| Add JoinRoomService.cs | `feat: 방 참여 서비스 추가` | +| Add new Entity class | `feat: {EntityName} 엔터티 추가` | +| Add new test class | `feat: {ServiceName} 테스트 추가` | +| Add new migration file | `feat: {MigrationName} 마이그레이션 추가` | + +--- + +## fix — Broken behavior or missing registration/config corrected + +Use when existing code is wrong, or a required wiring (DI, config key) is absent. +Adding only a DI registration line without adding the service file itself is also `fix`. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add missing `services.AddScoped()` | `fix: IRoomRepository DI 누락 수정` | +| Fix wrong CacheKey prefix | `fix: 세션 캐시 키 prefix 수정` | +| Fix SteamId type mismatch (ulong vs long) | `fix: SteamId 타입 불일치 수정` | +| Fix missing `[SessionAuthorize]` on endpoint | `fix: 방 참여 인증 누락 수정` | + +--- + +## update — Existing code modified without adding a new capability + +Use when modifying files that already exist — renaming, restructuring, adjusting behavior. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Change response type to `CommonApiResponse` | `update: 방 생성 응답 타입을 CommonApiResponse로 변경` | +| Modify entity domain method | `update: Room 참여 로직 수정` | +| Add a test method to an existing test class | `update: LoginService 테스트 추가` | +| Refactoring without behavior change | `update: RoomService 리팩토링` | +| Update appsettings connection string | `update: DB 연결 문자열 수정` | + +--- + +## Boundary rules + +| Situation | Type | +|---|---| +| New `.cs` service/repository/controller file added | `feat` | +| New method added to an existing `.cs` file | `update` | +| DI registration line added alone, no new service file | `fix` | +| New service file + its DI registration added together | `feat` (same logical unit) | +| New migration file added | `feat` | +| Existing migration file corrected | `fix` | +| New test class added | `feat` | +| Test method added to an existing test class | `update` | +| Refactoring without behavior change | `update` | + +--- + +## When to split into multiple commits + +``` +# New service + its DI registration → one logical unit, commit together +git add Domain/Room/Service/JoinRoomService.cs Domain/Room/Config/RoomServiceConfig.cs +git commit -m "feat: 방 참여 서비스 추가" + +# Separate DI fix → independent fix +git add Domain/Auth/Config/AuthServiceConfig.cs +git commit -m "fix: ISessionService DI 누락 수정" +``` diff --git a/PushAndPull/.claude/skills/db-migrate/SKILL.md b/PushAndPull/.claude/skills/db-migrate/SKILL.md new file mode 100644 index 0000000..e04cebd --- /dev/null +++ b/PushAndPull/.claude/skills/db-migrate/SKILL.md @@ -0,0 +1,79 @@ +--- +name: db-migrate +description: Manages EF Core migrations for PushAndPull. Supports add/update/list/remove subcommands. e.g. /db-migrate add AddRoomPasswordColumn +argument-hint: [add | update | list | remove] +allowed-tools: Bash(dotnet ef migrations:*), Bash(dotnet ef database:*), AskUserQuestion +context: fork +--- + +Manage EF Core migrations for PushAndPull. + +**Project**: `PushAndPull/PushAndPull.csproj` +**DbContext**: `AppDbContext` (`Global/Infrastructure/AppDbContext.cs`) + +## Current migrations state + +!`dotnet ef migrations list --project PushAndPull/PushAndPull.csproj 2>&1 || echo "(no migrations yet)"` + +--- + +## Dispatch on $ARGUMENTS + +Parse the first word of `$ARGUMENTS` as the subcommand. + +--- + +### `add ` + +1. Validate that a migration name was provided. If missing, use AskUserQuestion to ask: + > "마이그레이션 이름을 입력해주세요. (예: AddRoomDescriptionColumn)" +2. Run: + ```bash + dotnet ef migrations add {MigrationName} --project PushAndPull/PushAndPull.csproj + ``` +3. Report the generated files under `PushAndPull/Migrations/`. +4. Remind the user to review the generated `Up()` / `Down()` methods before applying. + +--- + +### `update` + +1. Show pending migrations from the list above (migrations not yet applied). +2. If there are no pending migrations, report "적용할 마이그레이션이 없습니다." and stop. +3. If there are pending migrations, run: + ```bash + dotnet ef database update --project PushAndPull/PushAndPull.csproj + ``` +4. Confirm success or surface any connection/schema errors. + +--- + +### `list` + +Run: +```bash +dotnet ef migrations list --project PushAndPull/PushAndPull.csproj +``` +Display the output, marking applied migrations with ✓ and pending ones with ○. + +--- + +### `remove` + +1. Warn the user: + > "마지막 마이그레이션을 삭제합니다. DB에 이미 적용된 경우 먼저 rollback이 필요합니다. 계속할까요?" +2. Use AskUserQuestion to confirm. +3. If confirmed, run: + ```bash + dotnet ef migrations remove --project PushAndPull/PushAndPull.csproj + ``` +4. Report which files were deleted. + +--- + +### Unknown subcommand + +If `$ARGUMENTS` is empty or doesn't match any subcommand, show: +``` +사용법: /db-migrate [add | update | list | remove] +``` diff --git a/PushAndPull/.claude/skills/db-migrate/examples/naming.md b/PushAndPull/.claude/skills/db-migrate/examples/naming.md new file mode 100644 index 0000000..b1db7c6 --- /dev/null +++ b/PushAndPull/.claude/skills/db-migrate/examples/naming.md @@ -0,0 +1,66 @@ +# Migration Naming Guide — Push & Pull Server + +## Format + +**PascalCase**, no spaces, clearly describes the schema operation being performed. + +--- + +## Naming patterns by operation + +### New table +``` +Create{TableName}Table +``` +e.g. `CreateRoomTable`, `CreateUserTable`, `CreateRoomMemberTable` + +### Initial schema (multiple tables at once) +``` +CreateTables +InitialSchema +``` + +### Add column to existing table +``` +Add{ColumnName}To{TableName} +``` +e.g. `AddPasswordToRoom`, `AddNicknameToUser`, `AddStatusToRoom` + +### Remove column +``` +Remove{ColumnName}From{TableName} +``` +e.g. `RemoveDeprecatedFieldFromRoom` + +### Fix column type, constraint, or misconfiguration +``` +Fix{ColumnName}{Issue} +Change{ColumnName}In{TableName} +``` +e.g. +- `FixRoomCodeUniqueConstraint` +- `ChangeStatusColumnTypeInRoom` + +### Add index +``` +Add{Description}IndexTo{TableName} +``` +e.g. `AddRoomCodeUniqueIndexToRoom`, `AddSteamIdIndexToUser` + +### Add foreign key +``` +Add{Relation}ForeignKeyTo{TableName} +``` +e.g. `AddRoomForeignKeyToRoomMember` + +--- + +## Anti-patterns (avoid) + +| Bad | Good | +|---|---| +| `Migration1` | `CreateRoomTable` | +| `FixRoom` | `FixRoomCodeUniqueConstraint` | +| `UpdateSchema` | `AddPasswordToRoom` | +| `Temp` | (describe what actually changed) | +| `Fix` | `FixCreatedAtColumnTypeInRoom` | diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/skills/pr/SKILL.md similarity index 62% rename from PushAndPull/.claude/commands/pr.md rename to PushAndPull/.claude/skills/pr/SKILL.md index 35ff547..e9412b0 100644 --- a/PushAndPull/.claude/commands/pr.md +++ b/PushAndPull/.claude/skills/pr/SKILL.md @@ -1,17 +1,26 @@ --- -description: Generate PR title suggestions and body based on changes from develop +name: pr +description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. 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 +context: fork --- Generate a PR based on the current branch. Behavior differs depending on the branch. -## Context +## Steps -- Current branch: !`git branch --show-current` +### Step 0. Initialize & Branch Discovery +1. Identify the current branch using `git branch --show-current`. +2. **Check for Arguments**: + - **If an argument is provided (e.g., `/pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. + - **If no argument is provided**: Follow the **Branch-Based Behavior** below: + - Current branch is `develop` → **Case 1** + - Current branch matches `release/x.x.x` → **Case 2** + - Any other branch → **Case 3** with `{Base Branch}` = `develop` --- -## Branch-Based Behavior +## Branch-Based Behavior (Default) ### Case 1: Current branch is `develop` @@ -31,16 +40,16 @@ Generate a PR based on the current branch. Behavior differs depending on the bra - **Patch** (0.0.x): Bug fixes only - Briefly explain why you chose that level -**Step 3. Ask user for version number** +**Step 3. Ask the user for a version number** Use AskUserQuestion: -> 현재 버전: {current_version} +> "현재 버전: {current_version} > 추천 버전 업: {Major/Minor/Patch} → {recommended_version} > 이유: {brief reason} > > 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" -**Step 4. Create release branch** +**Step 4. Create a release branch** ```bash git checkout -b release/{version} @@ -92,13 +101,13 @@ rm PR_BODY.md ### Case 3: Any other branch -**Step 1. Analyze changes from `develop`** +**Step 1. Analyze changes from `{Base Branch}`** -- Commits: `git log develop..HEAD --oneline` -- Diff stats: `git diff develop...HEAD --stat` -- Detailed diff: `git diff develop...HEAD` +- Commits: `git log {Base Branch}..HEAD --oneline` +- Diff stats: `git diff {Base Branch}...HEAD --stat` +- Detailed diff: `git diff {Base Branch}...HEAD` -**Step 2. Suggest 3 PR titles** following the PR Title Convention below +**Step 2. Suggest three PR titles** following the PR Title Convention below **Step 3. Write PR body** following the PR Body Template below - Save to `PR_BODY.md` @@ -116,16 +125,16 @@ rm PR_BODY.md [full body preview] ``` -**Step 5. Ask the user** using AskUserQuestion: -> "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" +**Step 5. Ask the user** using AskUserQuestion with a `choices` array: +- Options: the 3 generated titles + "직접 입력" as the last option +- If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title -**Step 6. Create PR to `develop`** +**Step 6. Create PR to `{Base Branch}`** -- If the user answered 1, 2, or 3, use the corresponding suggested title -- If the user typed a custom title, use it as-is +- Use the selected title, or the custom title if the user chose "직접 입력" ```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop +gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} ``` **Step 7. Delete PR_BODY.md** @@ -145,7 +154,8 @@ Format: `{type}: {Korean description}` - `fix` — bug fix or missing configuration/DI registration - `update` — modification to existing code - `refactor` — refactoring without behavior change -- `docs` - documentation changes +- `docs` — documentation changes +- `chore` — tooling, CI/CD, dependency updates **Rules:** - Description in Korean @@ -154,8 +164,10 @@ Format: `{type}: {Korean description}` **Examples:** - `feature: 방 생성 API 추가` -- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` -- `refactor: 로그인 로직 리팩토링` +- `fix: 세션 DI 누락 수정` +- `refactor: Room 서비스 리팩토링` + +See `.claude/skills/pr/examples/feature-to-develop.md` for a complete example. --- @@ -163,30 +175,10 @@ Format: `{type}: {Korean description}` 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] 변경한 기능이 다른 기능을 깨뜨리지 않나요? - - -> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* -``` +!.claude/skills/pr/templates/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 +- Keep the total body under 2500 characters - Write in Korean - No emojis in text content (keep the section header emojis) diff --git a/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md b/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md new file mode 100644 index 0000000..87b2ec7 --- /dev/null +++ b/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md @@ -0,0 +1,48 @@ +# Example: Feature Branch PR (feature → develop) + +## Branch context + +- Current branch: `feat/join-room-api` +- Base branch: `develop` + +## Suggested PR titles (3 options) + +1. `feature: 방 참여 API 추가` +2. `feature: Room 도메인 참여 엔드포인트 구현` +3. `feature: 방 참여 서비스·컨트롤러 추가` + +## Completed PR body example + +--- + +## 📚작업 내용 + +- JoinRoomService 구현 — 방 코드 조회 및 참여자 추가 +- RoomController에 `POST /api/v1/room/{roomCode}/join` 엔드포인트 추가 +- JoinRoomServiceTests 추가 — 정상 참여 및 예외 케이스 검증 +- RoomServiceConfig에 JoinRoomService DI 등록 + +## ◀️참고 사항 + +비공개 방 참여 시 비밀번호 검증은 `IPasswordHasher`를 통해 처리됩니다. +방이 가득 찬 경우 `ConflictException`을 반환합니다. + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* + +--- + +## Writing rules + +- **작업 내용 bullets**: group by meaningful change, not by raw commit +- **참고 사항**: configuration notes, before/after comparisons, etc. Use `"."` if nothing to add +- Keep the total body under 2500 characters +- All text content in Korean (keep section header emojis as-is) +- No emojis in body text — section headers only diff --git a/PushAndPull/.claude/skills/pr/templates/pr-body.md b/PushAndPull/.claude/skills/pr/templates/pr-body.md new file mode 100644 index 0000000..4817475 --- /dev/null +++ b/PushAndPull/.claude/skills/pr/templates/pr-body.md @@ -0,0 +1,18 @@ +## 📚작업 내용 + +- {change item 1} +- {change item 2} + +## ◀️참고 사항 + +{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* diff --git a/PushAndPull/.claude/skills/test/SKILL.md b/PushAndPull/.claude/skills/test/SKILL.md new file mode 100644 index 0000000..6bfb2dd --- /dev/null +++ b/PushAndPull/.claude/skills/test/SKILL.md @@ -0,0 +1,60 @@ +--- +name: test +description: Builds PushAndPull then runs all tests in PushAndPull.Test. Reports pass/fail results. e.g. /test LoginServiceTests +argument-hint: [ClassName or MethodName (optional)] +allowed-tools: Bash(dotnet build:*), Bash(dotnet test:*) +context: fork +--- + +Build the server project, then run the test suite and report results. + +## Steps + +### Step 1 — Build + +```bash +dotnet build PushAndPull/PushAndPull.csproj +``` + +- Build **fails**: list each error with its file path and line number, explain the likely cause, then stop. +- Build **succeeds**: continue to Step 2. + +### Step 2 — Run Tests + +If `$ARGUMENTS` is empty, run all tests: + +```bash +dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build +``` + +If `$ARGUMENTS` is provided, filter by class or method name: + +```bash +dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build --filter "FullyQualifiedName~$ARGUMENTS" +``` + +### Step 3 — Report Results + +Report in this format: + +``` +Test Results + +Passed: N +Failed: N +Skipped: N +``` + +If all tests pass, add: "All tests passed." + +If any tests fail, list each failure: + +``` +Failed Tests: + +- {FullyQualifiedTestName} + Error: {exception type and message} + Location: {file path and line number if available} +``` + +Do not truncate error messages. If the failure output contains an inner exception, include it. diff --git a/PushAndPull/.claude/skills/test/examples/filter-patterns.md b/PushAndPull/.claude/skills/test/examples/filter-patterns.md new file mode 100644 index 0000000..09f3cc8 --- /dev/null +++ b/PushAndPull/.claude/skills/test/examples/filter-patterns.md @@ -0,0 +1,70 @@ +# Test Filter Patterns — Push & Pull Server + +## How filtering works + +```bash +dotnet test --filter "FullyQualifiedName~{value}" +``` + +The `~` operator matches any fully qualified test name that **contains** the value as a substring. + +Fully qualified name format: +``` +{Namespace}.{OuterClass}+{InnerClass}.{MethodName} +``` + +Example: +``` +PushAndPull.Test.Service.Auth.LoginServiceTests+WhenANewUserLogsInForTheFirstTime.It_CreatesANewUser +``` + +--- + +## Current test classes in this project + +| `/test` argument | What runs | +|---|---| +| _(no argument)_ | All tests | +| `LoginServiceTests` | All LoginService tests | +| `LogoutServiceTests` | All LogoutService tests | +| `CreateRoomServiceTests` | All CreateRoomService tests | +| `GetAllRoomServiceTests` | All GetAllRoomService tests | +| `GetRoomServiceTests` | All GetRoomService tests | +| `JoinRoomServiceTests` | All JoinRoomService tests | +| `WhenANewUserLogsIn` | New user login scenario | +| `WhenAnExistingUserLogsIn` | Existing user login scenario | +| `It_CreatesANewUser` | Specific test method | + +--- + +## Pattern examples + +### Filter by outer class — runs all tests for a service +``` +/test LoginServiceTests +``` +→ Matches all of `PushAndPull.Test.Service.Auth.LoginServiceTests+*.*` + +### Filter by inner class — runs all tests in a scenario +``` +/test WhenANewUserLogsInForTheFirstTime +``` +→ Matches all methods inside that inner class + +### Filter by method name — runs a single test +``` +/test It_CreatesANewUser +``` +→ Matches the single method by name substring + +### Filter by service name +``` +/test JoinRoom +``` +→ Matches all of `PushAndPull.Test.Service.Room.JoinRoomServiceTests+*.*` + +### Filter by namespace — runs an entire domain +``` +/test PushAndPull.Test.Service.Auth +``` +→ Matches everything under the Auth service namespace From 9f8f9ba4d4a9bc0bc40c7a39664861d888fd4836 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:24:54 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20JoinRoom=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20IsPrivate=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/Room/JoinRoomServiceTests.cs | 29 +++++++++++++++++++ .../Domain/Room/Service/JoinRoomService.cs | 6 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs b/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs index a44a53a..dcdc9a4 100644 --- a/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs +++ b/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs @@ -63,6 +63,35 @@ await Assert.ThrowsAsync( } } + public class WhenAPrivateRoomIsJoinedWithoutAPassword + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomService _sut; + + private const string RoomCode = "PRIV02"; + + public WhenAPrivateRoomIsJoinedWithoutAPassword() + { + var privateRoom = new EntityRoom(RoomCode, "Private Room", 222UL, 76561198000000001UL, true, "some-hash"); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(privateRoom); + + _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsInvalidOperationExceptionWithPasswordRequiredMessage() + { + var ex = await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + + Assert.Equal("PASSWORD_REQUIRED", ex.Message); + } + } + public class WhenTheWrongPasswordIsProvidedForAPrivateRoom { private readonly Mock _roomRepositoryMock = new(); diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs index e834deb..96835ff 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs @@ -28,12 +28,12 @@ public async Task ExecuteAsync(JoinRoomCommand request) if (room.Status != RoomStatus.Active) throw new RoomNotActiveException(request.RoomCode); - if (request.Password != null) + if (room.IsPrivate) { - if (string.IsNullOrWhiteSpace(request.Password)) + if (request.Password == null) throw new InvalidOperationException("PASSWORD_REQUIRED"); - if (!_passwordHasher.Verify( request.Password, room.PasswordHash!)) + if (!_passwordHasher.Verify(request.Password, room.PasswordHash!)) throw new InvalidOperationException("INVALID_PASSWORD"); } From 10b47454f6e97073c7054d974970183c8e2e9282 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:24:58 +0900 Subject: [PATCH 06/13] =?UTF-8?q?update:=20Auth=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20CommonApiResponse=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Auth/Controller/AuthController.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs b/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs index cd1801a..ed083e9 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs @@ -24,27 +24,26 @@ ILogoutService logoutService } [HttpPost("login")] - public async Task Login( + public async Task> Login( [FromBody] LoginRequest request ) { var result = await _loginService.ExecuteAsync(new LoginCommand( request.SteamTicket, request.Nickname - ) - ); + )); - return new LoginResponse(result.SessionId); + return CommonApiResponse.Success("로그인되었습니다.", new LoginResponse(result.SessionId)); } [SessionAuthorize] [HttpPost("logout")] - public async Task Logout() + public async Task Logout() { var sessionId = User.GetSessionId(); - await _logoutService.ExecuteAsync( - new LogoutCommand(sessionId) - ); + await _logoutService.ExecuteAsync(new LogoutCommand(sessionId)); + + return CommonApiResponse.Success("로그아웃되었습니다."); } } From c2d27c2fcad3e2f37371bc32c601e25ea4da3a60 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:25:01 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20UserRepository=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20EF=20Core=20AddAsync=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Auth/Repository/UserRepository.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs b/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs index 4f2800a..37e444f 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs @@ -23,17 +23,8 @@ public UserRepository(AppDbContext context) public async Task CreateAsync(User user, CancellationToken ct = default) { - await _context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO game_user."user" (steam_id, nickname, created_at, last_login_at) - VALUES ({0}, {1}, {2}, {3}) - ON CONFLICT (steam_id) DO UPDATE - SET nickname = EXCLUDED.nickname, - last_login_at = EXCLUDED.last_login_at - """, - [user.SteamId, user.Nickname, user.CreatedAt, user.LastLoginAt], - ct - ); + await _context.Users.AddAsync(user, ct); + await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(ulong steamId, string nickname, DateTime lastLoginAt, CancellationToken ct = default) From 37936094267d39130835b53b1d6a7cb195b4fedd Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:25:05 +0900 Subject: [PATCH 08/13] =?UTF-8?q?update:=20RoomSummary=EB=A5=BC=20GetRoomR?= =?UTF-8?q?esponse=EB=A1=9C=20=EB=8C=80=EC=B2=B4=ED=95=98=EA=B3=A0=20MaxPl?= =?UTF-8?q?ayers=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs | 4 +++- .../PushAndPull/Domain/Room/Service/GetAllRoomService.cs | 9 +++++---- .../Domain/Room/Service/Interface/IGetAllRoomService.cs | 6 ++++-- .../PushAndPull/Domain/Room/Service/RoomSummary.cs | 8 -------- 4 files changed, 12 insertions(+), 15 deletions(-) delete mode 100644 PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs diff --git a/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs b/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs index df2ba23..3cb9fab 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs @@ -22,6 +22,8 @@ public class Room public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? ExpiresAt { get; set; } + private const int DefaultMaxPlayers = 2; + protected Room() { } public Room( @@ -38,7 +40,7 @@ public Room( SteamLobbyId = steamLobbyId; HostSteamId = hostSteamId; CurrentPlayers = 1; - MaxPlayers = 2; + MaxPlayers = DefaultMaxPlayers; IsPrivate = isPrivate; PasswordHash = passwordHash; Status = RoomStatus.Active; diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs index 2e7fb35..87be1c6 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs @@ -1,3 +1,4 @@ +using PushAndPull.Domain.Room.Dto.Response; using PushAndPull.Domain.Room.Repository.Interface; using PushAndPull.Domain.Room.Service.Interface; @@ -16,15 +17,15 @@ public async Task ExecuteAsync(CancellationToken ct = default) { var rooms = await _roomRepository.GetAllAsync(ct); - var summaries = rooms - .Select(room => new RoomSummary( - room.RoomName, + var responses = rooms + .Select(room => new GetRoomResponse( room.RoomCode, + room.RoomName, room.CurrentPlayers, room.IsPrivate )) .ToList(); - return new GetAllRoomResult(summaries); + return new GetAllRoomResult(responses); } } diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs index e6af738..1135e7c 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs @@ -1,3 +1,5 @@ +using PushAndPull.Domain.Room.Dto.Response; + namespace PushAndPull.Domain.Room.Service.Interface; public interface IGetAllRoomService @@ -6,5 +8,5 @@ public interface IGetAllRoomService } public record GetAllRoomResult( - IReadOnlyList Rooms - ); + IReadOnlyList Rooms +); diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs b/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs deleted file mode 100644 index 67b2348..0000000 --- a/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PushAndPull.Domain.Room.Service; - -public record RoomSummary( - string RoomName, - string RoomCode, - int CurrentPlayers, - bool IsPrivate - ); From 1de9df3e3f0f28f1d778a33a24e6f812bece57f1 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:25:08 +0900 Subject: [PATCH 09/13] =?UTF-8?q?update:=20Room=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20CommonApiResponse=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Room/Controller/RoomController.cs | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs b/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs index d308742..59019c8 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs @@ -43,54 +43,43 @@ [FromBody] CreateRoomRequest request request.IsPrivate, request.Password, hostSteamId - ) - ); + )); return CommonApiResponse.Created("방이 생성되었습니다.", new CreateRoomResponse(result.RoomCode)); } [HttpGet("{roomCode}")] - public async Task GetRoom( + public async Task> GetRoom( [FromRoute] string roomCode ) { - var result = await _getRoomService.ExecuteAsync( - new GetRoomCommand(roomCode) - ); + var result = await _getRoomService.ExecuteAsync(new GetRoomCommand(roomCode)); - return new GetRoomResponse( + return CommonApiResponse.Success("방 조회 성공.", new GetRoomResponse( result.RoomCode, result.RoomName, result.CurrentPlayers, result.IsPrivate - ); + )); } [HttpGet("all")] - public async Task GetAllRoom(CancellationToken ct) + public async Task> GetAllRoom(CancellationToken ct) { var result = await _getAllRoomService.ExecuteAsync(ct); - return new GetAllRoomResponse( - result.Rooms.Select(r => new GetRoomResponse( - r.RoomCode, - r.RoomName, - r.CurrentPlayers, - r.IsPrivate - )).ToList() - ); + return CommonApiResponse.Success("방 목록 조회 성공.", new GetAllRoomResponse(result.Rooms)); } [SessionAuthorize] [HttpPost("{roomCode}/join")] - public async Task JoinRoom( + public async Task JoinRoom( [FromRoute] string roomCode, [FromBody] JoinRoomRequest request ) { - await _joinRoomService.ExecuteAsync(new JoinRoomCommand( - roomCode, - request.Password) - ); + await _joinRoomService.ExecuteAsync(new JoinRoomCommand(roomCode, request.Password)); + + return CommonApiResponse.Success("방에 참여했습니다."); } } From b871a4c78b5081cbc369c10078d91fb9f3018f48 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:38:12 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20JoinRoom=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B2=80=EC=A6=9D=EC=9D=84=20IsNullOrWhit?= =?UTF-8?q?eSpace=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs index 96835ff..514276e 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs @@ -30,7 +30,7 @@ public async Task ExecuteAsync(JoinRoomCommand request) if (room.IsPrivate) { - if (request.Password == null) + if (string.IsNullOrWhiteSpace(request.Password)) throw new InvalidOperationException("PASSWORD_REQUIRED"); if (!_passwordHasher.Verify(request.Password, room.PasswordHash!)) From a3514f1b9f786cf61a83bc0127d69c98f62ce829 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:38:21 +0900 Subject: [PATCH 11/13] =?UTF-8?q?update:=20pr=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=ED=97=88=EC=9A=A9=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/settings.local.json | 3 ++- PushAndPull/.claude/skills/pr/SKILL.md | 15 +++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/PushAndPull/.claude/settings.local.json b/PushAndPull/.claude/settings.local.json index f50f1d1..09b37a3 100644 --- a/PushAndPull/.claude/settings.local.json +++ b/PushAndPull/.claude/settings.local.json @@ -26,7 +26,8 @@ "Skill(commit)", "Skill(db-migrate)", "WebFetch(domain:github.com)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Skill(pr)" ] } } diff --git a/PushAndPull/.claude/skills/pr/SKILL.md b/PushAndPull/.claude/skills/pr/SKILL.md index e9412b0..5fbc92f 100644 --- a/PushAndPull/.claude/skills/pr/SKILL.md +++ b/PushAndPull/.claude/skills/pr/SKILL.md @@ -1,7 +1,7 @@ --- name: pr description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. -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 +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, Read, AskUserQuestion context: fork --- @@ -114,20 +114,15 @@ rm 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 with a `choices` array: -- Options: the 3 generated titles + "직접 입력" as the last option -- If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title +**Step 5. Ask the user** using AskUserQuestion — you MUST call this tool, do NOT print a text prompt: +- `question`: "PR 제목을 선택해주세요." +- `choices`: the 3 generated titles + "직접 입력" as the last option +- If the user selects "직접 입력", immediately call AskUserQuestion again with `question`: "PR 제목을 입력해주세요." **Step 6. Create PR to `{Base Branch}`** From 39b77e19db09ebb6b6089d050b23027dc25a37f2 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:44:23 +0900 Subject: [PATCH 12/13] =?UTF-8?q?chore:=20commit/pr=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/skills/commit/SKILL.md | 2 ++ PushAndPull/.claude/skills/pr/SKILL.md | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/PushAndPull/.claude/skills/commit/SKILL.md b/PushAndPull/.claude/skills/commit/SKILL.md index 1168fe7..d5f5797 100644 --- a/PushAndPull/.claude/skills/commit/SKILL.md +++ b/PushAndPull/.claude/skills/commit/SKILL.md @@ -17,6 +17,7 @@ Create Git commits following the project's commit conventions. - `fix` — bug fix, missing config, or missing DI registration - `update` — modification to existing code - `docs` — documentation changes +- `chore` — tooling, CI/CD, dependency updates, config changes without code behavior change **Description rules**: - Written in **Korean** @@ -47,6 +48,7 @@ See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and - Bug / missing registration fix → `fix` - Modification to existing code → `update` - Documentation changes → `docs` + - Tooling / config / dependency changes → `chore` 3. Group files by each logical unit 4. For each group: - Stage only the relevant files with `git add ` diff --git a/PushAndPull/.claude/skills/pr/SKILL.md b/PushAndPull/.claude/skills/pr/SKILL.md index 5fbc92f..bb490c2 100644 --- a/PushAndPull/.claude/skills/pr/SKILL.md +++ b/PushAndPull/.claude/skills/pr/SKILL.md @@ -2,7 +2,6 @@ name: pr description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, Read, AskUserQuestion -context: fork --- Generate a PR based on the current branch. Behavior differs depending on the branch. From fd86edd81d29be98e8297c862a8a94e2c6ca918c Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Wed, 1 Apr 2026 20:58:26 +0900 Subject: [PATCH 13/13] =?UTF-8?q?chore:=20.claude=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PushAndPull/.claude/rules/code-style.md | 46 +-------------- PushAndPull/.claude/rules/conventions.md | 60 +++----------------- PushAndPull/.claude/rules/domain-patterns.md | 1 - PushAndPull/.claude/rules/global-patterns.md | 10 +--- PushAndPull/.claude/rules/verify.md | 8 +-- 5 files changed, 16 insertions(+), 109 deletions(-) diff --git a/PushAndPull/.claude/rules/code-style.md b/PushAndPull/.claude/rules/code-style.md index 8eae99c..b1f54cf 100644 --- a/PushAndPull/.claude/rules/code-style.md +++ b/PushAndPull/.claude/rules/code-style.md @@ -1,6 +1,7 @@ --- -description: C# code style rules. Always applied for all .cs files. -alwaysApply: true +description: C# code style rules. Applied for all .cs files. +globs: ["**/*.cs"] +alwaysApply: false --- ## C# Code Style @@ -25,21 +26,6 @@ alwaysApply: true - Constructor-inject all dependencies; assign to `private readonly` fields. - Keep constructors minimal — no logic, only assignments. -```csharp -// Good -public class LoginService : ILoginService -{ - private readonly IAuthTicketValidator _validator; - private readonly ISessionService _sessionService; - - public LoginService(IAuthTicketValidator validator, ISessionService sessionService) - { - _validator = validator; - _sessionService = sessionService; - } -} -``` - ### DTOs - Always use `record` types. @@ -61,37 +47,11 @@ public record CreateRoomRequest( - State changes only through domain methods (`room.Join()`, `user.UpdateNickname()`). - No direct field mutation from outside the entity. -```csharp -public class Room -{ - protected Room() { } - - public Room(string roomCode, string roomName, ulong hostSteamId) - { - RoomCode = roomCode; - RoomName = roomName; - HostSteamId = hostSteamId; - } - - public void Join(ulong steamId) { /* domain logic */ } -} -``` - ### Service Command/Result Pattern - Each service interface defines its own `Command` (input) and `Result` (output) records. - Co-located in the same Interface file. -```csharp -public interface ILoginService -{ - Task ExecuteAsync(LoginCommand command, CancellationToken ct = default); -} - -public record LoginCommand(string Ticket, string Nickname); -public record LoginResult(string SessionId); -``` - ### Async - All I/O methods are `async Task` or `async Task`. diff --git a/PushAndPull/.claude/rules/conventions.md b/PushAndPull/.claude/rules/conventions.md index 01bb9a1..4286a62 100644 --- a/PushAndPull/.claude/rules/conventions.md +++ b/PushAndPull/.claude/rules/conventions.md @@ -1,6 +1,7 @@ --- -description: Naming, DTO, EF Core entity configuration, and cache conventions. Always applied. -alwaysApply: true +description: Naming, DTO, EF Core entity configuration, and cache conventions. Applied for .cs files. +globs: ["**/*.cs"] +alwaysApply: false --- ## Naming Conventions @@ -28,63 +29,18 @@ alwaysApply: true - Use `IEntityTypeConfiguration` Fluent API — **never DataAnnotations on entities**. - Place in `Domain/{Name}/Entity/Config/`. -- `AppDbContext.OnModelCreating` contains only: - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); -} -``` - -Example config: -```csharp -public class RoomConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("room"); - builder.HasKey(e => e.Id); - builder.Property(e => e.RoomCode).HasColumnName("room_code"); - builder.Property(e => e.Status) - .HasColumnName("status") - .HasConversion(); - builder.Property(e => e.CreatedAt) - .HasColumnName("created_at") - .HasColumnType("timestamptz"); - } -} -``` +- `AppDbContext.OnModelCreating` calls only `modelBuilder.ApplyConfigurationsFromAssembly(...)`. +- See `/db-migration-guide` skill for full examples. ## Cache (Redis) All Redis keys must go through `CacheKey` — hardcoded strings are **forbidden**. - -```csharp -// Correct -CacheKey.Session.ById(sessionId) - -// Forbidden -"session:" + sessionId -``` +- Correct: `CacheKey.Session.ById(sessionId)` +- Forbidden: `"session:" + sessionId` ## Domain DI Registration -Each domain exposes a single static extension method in `Config/`: - -```csharp -public static class RoomServiceConfig -{ - public static IServiceCollection AddRoomServices(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - return services; - } -} -``` - +Each domain exposes a single `Add{Domain}Services(this IServiceCollection)` extension method in `Config/`. Call all domain configs from `Program.cs`. **Never skip DI registration after adding a new service.** ## Read Queries diff --git a/PushAndPull/.claude/rules/domain-patterns.md b/PushAndPull/.claude/rules/domain-patterns.md index d7b692b..b19a3f9 100644 --- a/PushAndPull/.claude/rules/domain-patterns.md +++ b/PushAndPull/.claude/rules/domain-patterns.md @@ -45,7 +45,6 @@ public class CreateRoomService : ICreateRoomService - Interface in `Repository/Interface/`, implementation in `Repository/`. - Only repositories access `AppDbContext`. -- Use `AsNoTracking()` for read-only queries. ```csharp public class RoomRepository : IRoomRepository diff --git a/PushAndPull/.claude/rules/global-patterns.md b/PushAndPull/.claude/rules/global-patterns.md index 115f752..d3ae475 100644 --- a/PushAndPull/.claude/rules/global-patterns.md +++ b/PushAndPull/.claude/rules/global-patterns.md @@ -29,15 +29,7 @@ Sessions are stored in Redis via `ISessionService` / `ISessionStore`: - Session contains `SteamId` (`ulong`) - TTL: 15 days (configurable) -**All Redis keys must use `CacheKey` — no hardcoded strings:** - -```csharp -// Correct -CacheKey.Session.ById(sessionId) - -// Forbidden -"session:" + sessionId -``` +**All Redis keys must use `CacheKey` — no hardcoded strings.** ## Security diff --git a/PushAndPull/.claude/rules/verify.md b/PushAndPull/.claude/rules/verify.md index 255c467..b2aba9d 100644 --- a/PushAndPull/.claude/rules/verify.md +++ b/PushAndPull/.claude/rules/verify.md @@ -6,12 +6,12 @@ alwaysApply: false ## Build-and-Verify Workflow -After adding or modifying any `.cs` file, run `/test` without asking the user. +After any `.cs` file change, the `postToolUse` hook runs build and tests automatically. -**If the build fails:** fix the build errors, then run `/test` again. +**If the build fails:** fix the build errors. **If tests fail:** - Production code bug → fix the production code. -- Test is outdated or needs updating to match new behavior → fix the test code. +- Test is outdated → fix the test code. -Run `/test` again after fixing. Only consider the task complete when the build succeeds and all tests pass. +Only consider the task complete when the build succeeds and all tests pass.