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;
}
}
}