From dffc62ff48e4137faa84bae56dbb026d7516e423 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 12 Mar 2026 15:11:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20DB=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EB=B0=A9=20=EC=9E=85?= =?UTF-8?q?=EC=9E=A5=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Output/Persistence/IRoomRepository.cs | 2 +- .../UseCase/Room/JoinRoomUseCase.cs | 7 +- .../Auth/SteamAuthTicketValidator.cs | 10 +- .../Persistence/DbContext/AppDbContext.cs | 5 +- .../Persistence/Repository/RoomRepository.cs | 25 +-- ...19_AddRoomStatusCreatedAtIndex.Designer.cs | 151 ++++++++++++++++++ ...60312060119_AddRoomStatusCreatedAtIndex.cs | 31 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 148 +++++++++++++++++ PushAndPull/Server/Program.cs | 3 +- 9 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs create mode 100644 PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs create mode 100644 PushAndPull/Server/Migrations/AppDbContextModelSnapshot.cs diff --git a/PushAndPull/Server/Application/Port/Output/Persistence/IRoomRepository.cs b/PushAndPull/Server/Application/Port/Output/Persistence/IRoomRepository.cs index 48d8632..1723e89 100644 --- a/PushAndPull/Server/Application/Port/Output/Persistence/IRoomRepository.cs +++ b/PushAndPull/Server/Application/Port/Output/Persistence/IRoomRepository.cs @@ -7,6 +7,6 @@ public interface IRoomRepository Task GetAsync(string roomCode); Task> GetAllAsync(CancellationToken ct); Task CreateAsync(Room room); - Task UpdateAsync(Room room); + Task IncrementPlayerCountAsync(string roomCode); Task CloseAsync(string roomCode); } \ No newline at end of file diff --git a/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs b/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs index 779babf..7921ecb 100644 --- a/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs @@ -37,7 +37,10 @@ public async Task ExecuteAsync(JoinRoomCommand request) throw new InvalidOperationException("INVALID_PASSWORD"); } - room.Join(); - await _roomRepository.UpdateAsync(room); + room.Join(); // 도메인 선행 검증 (FULL_ROOM) + + var success = await _roomRepository.IncrementPlayerCountAsync(request.RoomCode); + if (!success) + throw new InvalidOperationException("FULL_ROOM"); } } \ No newline at end of file diff --git a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs index a09a0ad..3267b04 100644 --- a/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs +++ b/PushAndPull/Server/Infrastructure/Auth/SteamAuthTicketValidator.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Json; using System.Text.Json; using Server.Application.Port.Output; using Server.Domain.Exception.Auth; @@ -85,13 +86,8 @@ private string BuildSteamApiUrl(string ticket) private static async Task ParseResponseAsync( HttpResponseMessage response) { - var json = await response.Content.ReadAsStringAsync(); - - var steamResponse = JsonSerializer.Deserialize( - json, - JsonOptions - ); - + var steamResponse = await response.Content.ReadFromJsonAsync(JsonOptions); + if (steamResponse?.Response.Params == null) throw new SteamApiException("INVALID_RESPONSE"); diff --git a/PushAndPull/Server/Infrastructure/Persistence/DbContext/AppDbContext.cs b/PushAndPull/Server/Infrastructure/Persistence/DbContext/AppDbContext.cs index 4a5f0cc..746f8ea 100644 --- a/PushAndPull/Server/Infrastructure/Persistence/DbContext/AppDbContext.cs +++ b/PushAndPull/Server/Infrastructure/Persistence/DbContext/AppDbContext.cs @@ -95,10 +95,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(x => x.Status) .HasDatabaseName("idx_room_status"); - + entity.HasIndex(x => new { x.Status, x.IsPrivate }) .HasDatabaseName("idx_room_status_private"); + entity.HasIndex(x => new { x.Status, x.CreatedAt }) + .HasDatabaseName("idx_room_status_created_at"); + entity.Property(x => x.CreatedAt) .HasColumnName("created_at") .HasColumnType("timestamptz"); diff --git a/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs b/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs index e9822bb..01e6e23 100644 --- a/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs +++ b/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs @@ -36,21 +36,24 @@ public async Task CreateAsync(Room room) await _context.SaveChangesAsync(); } - public async Task UpdateAsync(Room room) + public async Task IncrementPlayerCountAsync(string roomCode) { - _context.Rooms.Update(room); - await _context.SaveChangesAsync(); + var updated = await _context.Rooms + .Where(x => x.RoomCode == roomCode + && x.Status == RoomStatus.Active + && x.CurrentPlayers < x.MaxPlayers) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CurrentPlayers, x => x.CurrentPlayers + 1)); + + return updated > 0; } public async Task CloseAsync(string roomCode) { - var room = await _context.Rooms - .FirstOrDefaultAsync(x => x.RoomCode == roomCode); - - if (room == null) - return; - - room.Close(); - await _context.SaveChangesAsync(); + await _context.Rooms + .Where(x => x.RoomCode == roomCode) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, RoomStatus.Closed) + .SetProperty(x => x.ExpiresAt, DateTimeOffset.UtcNow)); } } \ No newline at end of file diff --git a/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs b/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs new file mode 100644 index 0000000..9bf8ac3 --- /dev/null +++ b/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs @@ -0,0 +1,151 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Server.Infrastructure.Persistence.DbContext; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260312060119_AddRoomStatusCreatedAtIndex")] + partial class AddRoomStatusCreatedAtIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Server.Domain.Entity.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("CurrentPlayers") + .HasColumnType("integer") + .HasColumnName("current_players"); + + b.Property("ExpiresAt") + .HasColumnType("timestamptz") + .HasColumnName("expires_at"); + + b.Property("HostSteamId") + .HasColumnType("numeric(20,0)") + .HasColumnName("host_steam_id"); + + b.Property("IsPrivate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_private"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_code"); + + b.Property("RoomName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("room_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SteamLobbyId") + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_lobby_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("idx_room_expires_at"); + + b.HasIndex("HostSteamId") + .HasDatabaseName("idx_room_host_steam_id"); + + b.HasIndex("RoomCode") + .IsUnique() + .HasDatabaseName("idx_room_room_code"); + + b.HasIndex("Status") + .HasDatabaseName("idx_room_status"); + + b.HasIndex("Status", "CreatedAt") + .HasDatabaseName("idx_room_status_created_at"); + + b.HasIndex("Status", "IsPrivate") + .HasDatabaseName("idx_room_status_private"); + + b.ToTable("room", "room"); + }); + + modelBuilder.Entity("Server.Domain.Entity.User", b => + { + b.Property("SteamId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamptz") + .HasColumnName("last_login_at"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nickname"); + + b.HasKey("SteamId"); + + b.ToTable("user", "user"); + }); + + modelBuilder.Entity("Server.Domain.Entity.Room", b => + { + b.HasOne("Server.Domain.Entity.User", "Host") + .WithMany() + .HasForeignKey("HostSteamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Host"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs b/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs new file mode 100644 index 0000000..a0a0123 --- /dev/null +++ b/PushAndPull/Server/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Server.Migrations +{ + /// + public partial class AddRoomStatusCreatedAtIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "idx_room_status_created_at", + schema: "room", + table: "room", + columns: new[] { "status", "created_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "idx_room_status_created_at", + schema: "room", + table: "room"); + } + } +} diff --git a/PushAndPull/Server/Migrations/AppDbContextModelSnapshot.cs b/PushAndPull/Server/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..77a0524 --- /dev/null +++ b/PushAndPull/Server/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,148 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Server.Infrastructure.Persistence.DbContext; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Server.Domain.Entity.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("CurrentPlayers") + .HasColumnType("integer") + .HasColumnName("current_players"); + + b.Property("ExpiresAt") + .HasColumnType("timestamptz") + .HasColumnName("expires_at"); + + b.Property("HostSteamId") + .HasColumnType("numeric(20,0)") + .HasColumnName("host_steam_id"); + + b.Property("IsPrivate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_private"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_code"); + + b.Property("RoomName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("room_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("SteamLobbyId") + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_lobby_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("idx_room_expires_at"); + + b.HasIndex("HostSteamId") + .HasDatabaseName("idx_room_host_steam_id"); + + b.HasIndex("RoomCode") + .IsUnique() + .HasDatabaseName("idx_room_room_code"); + + b.HasIndex("Status") + .HasDatabaseName("idx_room_status"); + + b.HasIndex("Status", "CreatedAt") + .HasDatabaseName("idx_room_status_created_at"); + + b.HasIndex("Status", "IsPrivate") + .HasDatabaseName("idx_room_status_private"); + + b.ToTable("room", "room"); + }); + + modelBuilder.Entity("Server.Domain.Entity.User", b => + { + b.Property("SteamId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamptz") + .HasColumnName("last_login_at"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nickname"); + + b.HasKey("SteamId"); + + b.ToTable("user", "user"); + }); + + modelBuilder.Entity("Server.Domain.Entity.Room", b => + { + b.HasOne("Server.Domain.Entity.User", "Host") + .WithMany() + .HasForeignKey("HostSteamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Host"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PushAndPull/Server/Program.cs b/PushAndPull/Server/Program.cs index 618e3c2..2599029 100644 --- a/PushAndPull/Server/Program.cs +++ b/PushAndPull/Server/Program.cs @@ -27,7 +27,8 @@ builder.Services.AddDbContext(options => { - options.UseNpgsql(connectionString); + options.UseNpgsql(connectionString, npgsql => + npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "room")); }); builder.Services.AddSingleton(_ => From f4ad0e493bcef8e473742c635bdfd6b7d85b4251 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 12 Mar 2026 15:13:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20JoinRoomUseCaseTests=20UpdateAsync?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs index da7d018..0b7595f 100644 --- a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs @@ -115,16 +115,19 @@ public WhenAllConditionsAreValidForJoiningARoom() .Setup(r => r.GetAsync(RoomCode)) .ReturnsAsync(_activeRoom); + _roomRepositoryMock + .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .ReturnsAsync(true); + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); } [Fact] - public async Task It_UpdatesTheRoom() + public async Task It_CallsIncrementPlayerCount() { await _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null)); - _roomRepositoryMock.Verify(r => r.UpdateAsync( - It.Is(room => room.RoomCode == RoomCode)), Times.Once); + _roomRepositoryMock.Verify(r => r.IncrementPlayerCountAsync(RoomCode), Times.Once); } [Fact] From 3132e7295f9409a5fabd2f5f1c4ba5ed4b337c24 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 12 Mar 2026 15:17:20 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20IncrementPlayerCountAsync=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=A0=95=ED=99=95=ED=95=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../UseCase/Room/JoinRoomUseCase.cs | 8 ++ .../UseCase/Room/JoinRoomUseCaseTests.cs | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs b/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs index 7921ecb..16db191 100644 --- a/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs @@ -41,6 +41,14 @@ public async Task ExecuteAsync(JoinRoomCommand request) var success = await _roomRepository.IncrementPlayerCountAsync(request.RoomCode); if (!success) + { + var roomAfterAttempt = await _roomRepository.GetAsync(request.RoomCode); + if (roomAfterAttempt == null) + throw new RoomNotFoundException(request.RoomCode); + if (roomAfterAttempt.Status != RoomStatus.Active) + throw new RoomNotActiveException(request.RoomCode); + throw new InvalidOperationException("FULL_ROOM"); + } } } \ No newline at end of file diff --git a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs index 0b7595f..5e6e6a5 100644 --- a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs +++ b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs @@ -98,6 +98,110 @@ public async Task It_ThrowsInvalidOperationExceptionWithInvalidPasswordMessage() } } + public class WhenIncrementFailsBecauseRoomDisappearedConcurrently + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "GONE01"; + + public WhenIncrementFailsBecauseRoomDisappearedConcurrently() + { + var activeRoom = new EntityRoom(RoomCode, "Disappearing Room", 444UL, 76561198000000001UL, false, null); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(activeRoom); + + _roomRepositoryMock + .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .ReturnsAsync(false); + + // 재조회 시 방이 사라진 상태 + _roomRepositoryMock + .SetupSequence(r => r.GetAsync(RoomCode)) + .ReturnsAsync(activeRoom) + .ReturnsAsync((EntityRoom?)null); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsRoomNotFoundException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + } + } + + public class WhenIncrementFailsBecauseRoomBecameInactiveConcurrently + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "CLOS02"; + + public WhenIncrementFailsBecauseRoomBecameInactiveConcurrently() + { + var activeRoom = new EntityRoom(RoomCode, "Closing Room", 555UL, 76561198000000001UL, false, null); + var closedRoom = new EntityRoom(RoomCode, "Closing Room", 555UL, 76561198000000001UL, false, null); + closedRoom.Close(); + + _roomRepositoryMock + .SetupSequence(r => r.GetAsync(RoomCode)) + .ReturnsAsync(activeRoom) + .ReturnsAsync(closedRoom); + + _roomRepositoryMock + .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .ReturnsAsync(false); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsRoomNotActiveException() + { + await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + } + } + + public class WhenIncrementFailsBecauseRoomIsFullConcurrently + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomUseCase _sut; + + private const string RoomCode = "FULL01"; + + public WhenIncrementFailsBecauseRoomIsFullConcurrently() + { + var activeRoom = new EntityRoom(RoomCode, "Full Room", 666UL, 76561198000000001UL, false, null); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode)) + .ReturnsAsync(activeRoom); + + _roomRepositoryMock + .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .ReturnsAsync(false); + + _sut = new JoinRoomUseCase(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsFullRoomException() + { + var ex = await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + + Assert.Equal("FULL_ROOM", ex.Message); + } + } + public class WhenAllConditionsAreValidForJoiningARoom { private readonly Mock _roomRepositoryMock = new(); From fe70ac481e145519a7a5447f44574cc99ec1dc9b Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 12 Mar 2026 15:46:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?modify:=20CloseAsync=EC=97=90=20Room.Close(?= =?UTF-8?q?)=20=EB=A1=9C=EC=A7=81=20=EA=B2=B0=ED=95=A9=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Infrastructure/Persistence/Repository/RoomRepository.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs b/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs index 01e6e23..530762b 100644 --- a/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs +++ b/PushAndPull/Server/Infrastructure/Persistence/Repository/RoomRepository.cs @@ -48,6 +48,8 @@ public async Task IncrementPlayerCountAsync(string roomCode) return updated > 0; } + // 주의: 이 메서드는 Room.Close()의 로직(Status = Closed, ExpiresAt = UtcNow)을 직접 반영하고 있습니다. + // Room.Close()에 새로운 비즈니스 로직이 추가될 경우 이 메서드도 함께 수정해야 합니다. public async Task CloseAsync(string roomCode) { await _context.Rooms