using Application.Abstractions.Chat; using Application.Helpers; using Domain.Entities.Chat; using HistoryHandler = Application.Features.Api.Chat.GetHistory.Handler; using HistoryQuery = Application.Features.Api.Chat.GetHistory.Query; namespace Application.Tests; [TestClass] public sealed class ChatRoomTests { // ── ChatRoomKeys 공개 계약 검증 ────────────────────────────── [TestMethod] public void RoomKeys_MainAndStock_AreValid() { // 기대 행위: 공개 계약 roomKey 는 main 과 stock:{6자리 숫자}만 허용한다. Assert.IsTrue(ChatRoomKeys.IsValid("main")); Assert.IsTrue(ChatRoomKeys.IsValid("stock:005930")); Assert.IsFalse(ChatRoomKeys.IsValid(null), "null 거부"); Assert.IsFalse(ChatRoomKeys.IsValid(" "), "공백 거부"); Assert.IsFalse(ChatRoomKeys.IsValid("MAIN"), "대문자 거부"); Assert.IsFalse(ChatRoomKeys.IsValid("stock:12345"), "5자리 코드 거부"); Assert.IsFalse(ChatRoomKeys.IsValid("stock:12345A"), "숫자 아닌 코드 거부"); Assert.IsFalse(ChatRoomKeys.IsValid("channel:abcd"), "레거시 채널 키는 공개 계약이 아니다"); Assert.IsFalse(ChatRoomKeys.IsValid("stock:0059301234567890"), "20자 초과 거부"); } [TestMethod] public void RoomOpen_MainDefaultsOpen_StockRequiresActiveRow() { // 기대 행위: main 은 row 없어도 오픈, 종목방은 row 존재 + IsActive 필요 (단계 오픈 — d0 §5-④). var missing = new ChatRoomConfigLoader.RoomConfig(false, false, 0, null); var active = new ChatRoomConfigLoader.RoomConfig(true, true, 0, null); var inactive = new ChatRoomConfigLoader.RoomConfig(true, false, 0, null); Assert.IsTrue(ChatRoomConfigLoader.IsRoomOpen("main", missing), "main 은 기본 오픈"); Assert.IsFalse(ChatRoomConfigLoader.IsRoomOpen("main", inactive), "main 도 명시 비활성 시 닫힘"); Assert.IsFalse(ChatRoomConfigLoader.IsRoomOpen("stock:005930", missing), "종목방은 row 없으면 닫힘"); Assert.IsTrue(ChatRoomConfigLoader.IsRoomOpen("stock:005930", active), "종목방은 활성 row 시 오픈"); Assert.IsTrue(ChatRoomConfigLoader.IsRoomOpen("channel:legacy", missing), "레거시 채널 룸은 게이트 없음"); } // ── 히스토리 API (익명 허용 — 게스트 읽기) ───────────────── [TestMethod] public async Task History_InvalidRoomKey_NotFound() { // 기대 행위: 계약 외 roomKey 는 NotFound 로 거절한다. using var db = TestDb.Create(); var handler = new HistoryHandler(db, new FakeChatMessageStore()); var result = await handler.Handle(new HistoryQuery("channel:hack", 50), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("Chat.RoomNotFound", result.Error.Code); } [TestMethod] public async Task History_MainWithoutConfigRow_ReturnsOpenDefaults() { // 기대 행위: main 은 설정 row 없이도 조회 가능하며 기본값(슬로우모드 0, 공지 없음)을 반환한다. using var db = TestDb.Create(); var store = new FakeChatMessageStore(); await store.AddMessageAsync("main", CreateMessage(1, "첫 메시지")); await store.AddMessageAsync("main", CreateMessage(2, "둘째 메시지")); var handler = new HistoryHandler(db, store); var result = await handler.Handle(new HistoryQuery("main", 50), default); Assert.IsTrue(result.IsSuccess, "main 히스토리는 항상 조회 가능해야 한다"); Assert.AreEqual("main", result.Value.RoomKey); Assert.AreEqual((short)0, result.Value.SlowModeSeconds); Assert.IsNull(result.Value.Notice); Assert.AreEqual(2, result.Value.Messages.Count); Assert.AreEqual("첫 메시지", result.Value.Messages[0].Content, "오래된 → 최신 순서"); } [TestMethod] public async Task History_StockRoomWithoutConfig_NotFound() { // 기대 행위: 단계 오픈 전 종목방(설정 row 없음)은 NotFound. using var db = TestDb.Create(); var handler = new HistoryHandler(db, new FakeChatMessageStore()); var result = await handler.Handle(new HistoryQuery("stock:940001", 50), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("Chat.RoomNotOpen", result.Error.Code); } [TestMethod] public async Task History_StockRoomActiveConfig_ReturnsMeta() { // 기대 행위: 활성 종목방은 공지/슬로우모드 메타와 메시지를 함께 반환한다. using var db = TestDb.Create(); await db.ChatRoomConfig.AddAsync(ChatRoomConfig.Create("stock:940002", isActive: true, slowModeSeconds: 5, notice: "종목방 공지")); await db.SaveChangesAsync(default); var store = new FakeChatMessageStore(); await store.AddMessageAsync("stock:940002", CreateMessage(1, "종목방 메시지")); var handler = new HistoryHandler(db, store); var result = await handler.Handle(new HistoryQuery("stock:940002", 50), default); Assert.IsTrue(result.IsSuccess); Assert.AreEqual((short)5, result.Value.SlowModeSeconds); Assert.AreEqual("종목방 공지", result.Value.Notice); Assert.AreEqual(1, result.Value.Messages.Count); } [TestMethod] public async Task History_Count_ClampedToMaxMessages() { // 기대 행위: count 는 1~ChatSettings.MaxMessages 로 클램프되고, 0 이하는 기본값(50)이 된다. using var db = TestDb.Create(); var store = new FakeChatMessageStore(); var handler = new HistoryHandler(db, store); await handler.Handle(new HistoryQuery("main", 99999), default); Assert.AreEqual(ChatSettings.MaxMessages, store.LastRequestedCount, "상한은 MaxMessages"); await handler.Handle(new HistoryQuery("main", 0), default); Assert.AreEqual(HistoryHandler.DefaultCount, store.LastRequestedCount, "0 이하는 기본값"); } // ── 채팅 제재 (ChatBan) — roomKey 체계 ─────────────────────── [TestMethod] public async Task Ban_GlobalBan_BlocksAllRooms() { // 기대 행위: RoomKey NULL 제재는 모든 룸에서 발언을 차단한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); await db.ChatBan.AddAsync(ChatBan.Create(memberID, null, "전체 제재", null, "admin")); await db.SaveChangesAsync(default); var now = DateTime.UtcNow; Assert.IsTrue(await ChatBanChecker.IsBannedAsync(db, memberID, "main", now, default)); Assert.IsTrue(await ChatBanChecker.IsBannedAsync(db, memberID, "stock:005930", now, default)); } [TestMethod] public async Task Ban_RoomBan_BlocksOnlyThatRoom() { // 기대 행위: 룸 지정 제재는 해당 룸만 차단하고 다른 룸 발언은 허용한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); await db.ChatBan.AddAsync(ChatBan.Create(memberID, "main", "메인 룸 제재", null, "admin")); await db.SaveChangesAsync(default); var now = DateTime.UtcNow; Assert.IsTrue(await ChatBanChecker.IsBannedAsync(db, memberID, "main", now, default)); Assert.IsFalse(await ChatBanChecker.IsBannedAsync(db, memberID, "stock:005930", now, default)); } [TestMethod] public async Task Ban_Expired_DoesNotBlock() { // 기대 행위: 만료된 제재는 차단하지 않는다 (Lift 로 즉시 해제 포함). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var now = DateTime.UtcNow; await db.ChatBan.AddAsync(ChatBan.Create(memberID, null, "만료 제재", now.AddMinutes(-1), "admin")); await db.SaveChangesAsync(default); Assert.IsFalse(await ChatBanChecker.IsBannedAsync(db, memberID, "main", now, default)); } // ── 테스트 헬퍼 ────────────────────────────────────────────── private static ChatMessage CreateMessage(int memberID, string content) { return new ChatMessage(memberID, $"SID{memberID:D4}", $"회원{memberID}", content, DateTime.UtcNow); } /// Redis 대체 인메모리 스토어 — 요청 count 기록으로 클램프 검증 지원 private sealed class FakeChatMessageStore : IChatMessageStore { private readonly Dictionary> _rooms = []; public int LastRequestedCount { get; private set; } public Task AddMessageAsync(string roomKey, ChatMessage message) { if (!_rooms.TryGetValue(roomKey, out var list)) { list = []; _rooms[roomKey] = list; } list.Add(message); return Task.CompletedTask; } public Task> GetRecentMessagesAsync(string roomKey, int count) { LastRequestedCount = count; var list = _rooms.TryGetValue(roomKey, out var messages) ? messages : []; return Task.FromResult>([..list.TakeLast(count)]); } public Task ClearRoomAsync(string roomKey) { _rooms.Remove(roomKey); return Task.CompletedTask; } } }