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..16db191 100644 --- a/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs +++ b/PushAndPull/Server/Application/UseCase/Room/JoinRoomUseCase.cs @@ -37,7 +37,18 @@ 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) + { + 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/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(_ => diff --git a/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs b/PushAndPull/Tests/UseCase/Room/JoinRoomUseCaseTests.cs index da7d018..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(); @@ -115,16 +219,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]