From fce9777c1116d863cb0c1821d98740a04e4eebb0 Mon Sep 17 00:00:00 2001 From: cheesecrust Date: Fri, 29 Aug 2025 13:51:40 +0900 Subject: [PATCH 1/2] Fix: fis query --- .../repository/InventoryItemRepository.java | 7 +- .../service/InventoryServiceTest.java | 210 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/ssafy/pookie/inventory/service/InventoryServiceTest.java diff --git a/backend/src/main/java/com/ssafy/pookie/inventory/repository/InventoryItemRepository.java b/backend/src/main/java/com/ssafy/pookie/inventory/repository/InventoryItemRepository.java index 7dd61d44..f422345d 100644 --- a/backend/src/main/java/com/ssafy/pookie/inventory/repository/InventoryItemRepository.java +++ b/backend/src/main/java/com/ssafy/pookie/inventory/repository/InventoryItemRepository.java @@ -2,6 +2,8 @@ import com.ssafy.pookie.inventory.model.InventoryItem; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,7 +12,10 @@ @Repository public interface InventoryItemRepository extends JpaRepository { Optional findByUserAccountIdxAndStoreItem_Idx(Long userAccountIdx, Long itemIdx); - List findAllByUserAccountIdx(Long userAccountIdx); + + @Query("SELECT i FROM InventoryItem i JOIN FETCH i.storeItem WHERE i.userAccountIdx = :userAccountIdx") + List findAllByUserAccountIdx(@Param("userAccountIdx") Long userAccountIdx); + Optional findByUserAccountIdxAndIdx(Long userAccountIdx, Long inventoryIdx); } diff --git a/backend/src/test/java/com/ssafy/pookie/inventory/service/InventoryServiceTest.java b/backend/src/test/java/com/ssafy/pookie/inventory/service/InventoryServiceTest.java new file mode 100644 index 00000000..2c8dc560 --- /dev/null +++ b/backend/src/test/java/com/ssafy/pookie/inventory/service/InventoryServiceTest.java @@ -0,0 +1,210 @@ +package com.ssafy.pookie.inventory.service; + +import com.ssafy.pookie.auth.model.UserAccounts; +import com.ssafy.pookie.auth.repository.UserAccountsRepository; +import com.ssafy.pookie.inventory.dto.InventoryItemResponseDto; +import com.ssafy.pookie.inventory.model.InventoryItem; +import com.ssafy.pookie.inventory.repository.InventoryItemRepository; +import com.ssafy.pookie.store.model.StoreItem; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class InventoryServiceTest { + + @Autowired + private InventoryService inventoryService; + + @Autowired + private InventoryItemRepository inventoryItemRepository; + + @Autowired + private UserAccountsRepository userAccountsRepository; + + @Autowired + private SessionFactory sessionFactory; + + private UserAccounts testUser; + private StoreItem testItem1; + private StoreItem testItem2; + private StoreItem testItem3; + + @BeforeEach + void setUp() { + testUser = UserAccounts.builder() + .nickname("testUser") + .build(); + testUser = userAccountsRepository.save(testUser); + + testItem1 = StoreItem.builder() + .name("Test Item 1") + .image("item1.png") + .exp(10) + .price(100) + .build(); + + testItem2 = StoreItem.builder() + .name("Test Item 2") + .image("item2.png") + .exp(20) + .price(200) + .build(); + + testItem3 = StoreItem.builder() + .name("Test Item 3") + .image("item3.png") + .exp(30) + .price(300) + .build(); + + Session session = sessionFactory.getCurrentSession(); + session.persist(testItem1); + session.persist(testItem2); + session.persist(testItem3); + session.flush(); + + InventoryItem inventoryItem1 = InventoryItem.builder() + .userAccountIdx(testUser.getId()) + .storeItem(testItem1) + .amount(5) + .build(); + + InventoryItem inventoryItem2 = InventoryItem.builder() + .userAccountIdx(testUser.getId()) + .storeItem(testItem2) + .amount(3) + .build(); + + InventoryItem inventoryItem3 = InventoryItem.builder() + .userAccountIdx(testUser.getId()) + .storeItem(testItem3) + .amount(2) + .build(); + + inventoryItemRepository.save(inventoryItem1); + inventoryItemRepository.save(inventoryItem2); + inventoryItemRepository.save(inventoryItem3); + session.flush(); + session.clear(); + } + + @Test + void testGetAllInventoryItems_NoN1Problem() { + Statistics statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + List result = inventoryService.getAllInventoryItems(testUser.getId()); + + long queryCount = statistics.getQueryExecutionCount(); + + assertEquals(3, result.size()); + + assertTrue(queryCount <= 2, + String.format("Expected query count <= 2 (1 for user lookup + 1 for inventory with JOIN FETCH), but got %d queries", queryCount)); + + InventoryItemResponseDto item1 = result.stream() + .filter(item -> item.getItemName().equals("Test Item 1")) + .findFirst() + .orElse(null); + + assertNotNull(item1); + assertEquals("Test Item 1", item1.getItemName()); + assertEquals("item1.png", item1.getImage()); + assertEquals(10, item1.getExp()); + assertEquals(5, item1.getAmount()); + + System.out.println("Total queries executed: " + queryCount); + System.out.println("Successfully avoided N+1 problem!"); + } + + @Test + void testGetAllInventoryItems_QueryCountWithMultipleItems() { + for (int i = 4; i <= 10; i++) { + StoreItem additionalItem = StoreItem.builder() + .name("Additional Item " + i) + .image("item" + i + ".png") + .exp(i * 10) + .price(i * 100) + .build(); + + Session session = sessionFactory.getCurrentSession(); + session.persist(additionalItem); + + InventoryItem additionalInventoryItem = InventoryItem.builder() + .userAccountIdx(testUser.getId()) + .storeItem(additionalItem) + .amount(1) + .build(); + + inventoryItemRepository.save(additionalInventoryItem); + } + + Statistics statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + List result = inventoryService.getAllInventoryItems(testUser.getId()); + + long queryCount = statistics.getQueryExecutionCount(); + + assertEquals(10, result.size()); + + assertTrue(queryCount <= 2, + String.format("With 10 items, expected query count <= 2, but got %d queries. This indicates N+1 problem!", queryCount)); + + System.out.println("Total items: " + result.size()); + System.out.println("Total queries executed: " + queryCount); + } + + @Test + void testInventoryItemResponseDto_PropertyMapping() { + List result = inventoryService.getAllInventoryItems(testUser.getId()); + + InventoryItemResponseDto item = result.get(0); + + assertNotNull(item.getIdx()); + assertNotNull(item.getUserAccountIdx()); + assertNotNull(item.getItemIdx()); + assertNotNull(item.getItemName()); + assertNotNull(item.getImage()); + assertTrue(item.getExp() > 0); + assertTrue(item.getAmount() > 0); + + assertEquals(testUser.getId(), item.getUserAccountIdx()); + } + + @Test + void testGetAllInventoryItems_EmptyInventory() { + UserAccounts emptyUser = UserAccounts.builder() + .nickname("emptyUser") + .build(); + emptyUser = userAccountsRepository.save(emptyUser); + + Statistics statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + List result = inventoryService.getAllInventoryItems(emptyUser.getId()); + + long queryCount = statistics.getQueryExecutionCount(); + + assertTrue(result.isEmpty()); + assertTrue(queryCount <= 2, "Even with empty inventory, should not have N+1 problem"); + + System.out.println("Empty inventory queries executed: " + queryCount); + } +} \ No newline at end of file From d5a53af868fbe9d188e39a961abd02a07f847c3e Mon Sep 17 00:00:00 2001 From: cheesecrust Date: Sun, 31 Aug 2025 12:59:44 +0900 Subject: [PATCH 2/2] Feature: add chat abuse filter --- .../chat/filter/BurstAllowedChatFilter.java | 153 ++++++++++ .../filter/BurstAllowedChatFilterTest.java | 268 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 backend/src/main/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilter.java create mode 100644 backend/src/test/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilterTest.java diff --git a/backend/src/main/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilter.java b/backend/src/main/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilter.java new file mode 100644 index 00000000..d42a638b --- /dev/null +++ b/backend/src/main/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilter.java @@ -0,0 +1,153 @@ +package com.ssafy.pookie.game.chat.filter; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.regex.Pattern; + +@Component +public class BurstAllowedChatFilter { + + private static final int BURST_LIMIT = 3; + private static final long COOLDOWN_MS = 1000; + private static final long BURST_RESET_MS = 5000; + private static final int MAX_MESSAGE_LENGTH = 200; + private static final long CLEANUP_INTERVAL_MS = 60000; + + private static final Pattern SPAM_PATTERN = Pattern.compile("^(.)\\1{9,}$"); + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("^\\s*$"); + private static final Pattern ALLOWED_REACTIONS = Pattern.compile("^[ㅋㅎㅠㄷㄱㅇ와헉]{1,10}$"); + + private final ConcurrentMap userStates = new ConcurrentHashMap<>(); + private volatile long lastCleanup = System.currentTimeMillis(); + + public boolean canSend(String userId, String message) { + if (!isValidInput(userId, message)) { + return false; + } + + if (isSpamMessage(message)) { + return false; + } + + performPeriodicCleanup(); + + UserChatState userState = getUserState(userId); + return userState.canSendMessage(message); + } + + private boolean isValidInput(String userId, String message) { + return userId != null && !userId.trim().isEmpty() && + message != null && !WHITESPACE_PATTERN.matcher(message).matches() && + message.length() <= MAX_MESSAGE_LENGTH; + } + + private boolean isSpamMessage(String message) { + // 단순하게: 스팸 패턴만 체크 (길이 10개 이상 동일 문자 반복) + return SPAM_PATTERN.matcher(message).matches(); + } + + private UserChatState getUserState(String userId) { + return userStates.computeIfAbsent(userId, k -> new UserChatState()); + } + + private void performPeriodicCleanup() { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastCleanup > CLEANUP_INTERVAL_MS) { + cleanupOldUsers(currentTime); + lastCleanup = currentTime; + } + } + + private void cleanupOldUsers(long currentTime) { + userStates.entrySet().removeIf(entry -> + currentTime - entry.getValue().getLastActivity() > BURST_RESET_MS * 2 + ); + } + + private static class UserChatState { + private int burstCount = 0; + private long lastMessageTime = 0; + private long burstStartTime = 0; + private String lastMessage = ""; + private long lastActivity = System.currentTimeMillis(); + + public synchronized boolean canSendMessage(String message) { + long currentTime = System.currentTimeMillis(); + lastActivity = currentTime; + + if (isRepeatedMessage(message)) { + return false; + } + + if (shouldResetBurst(currentTime)) { + resetBurst(currentTime); + } + + // 감정 표현은 버스트 카운트에서 제외 + boolean isEmotionalExpression = isEmotionalExpression(message); + + if (burstCount < BURST_LIMIT || isEmotionalExpression) { + recordMessage(message, currentTime, !isEmotionalExpression); + return true; + } + + if (isInCooldown(currentTime)) { + return false; + } + + recordMessage(message, currentTime, true); + return true; + } + + private boolean isRepeatedMessage(String message) { + // 감정 표현은 반복 허용 + if (message.equals("ㅋㅋㅋ") || message.equals("ㅠㅠ") || message.equals("ㅎㅎ") || + message.equals("ㄷㄷ") || message.equals("ㄱㄱ") || message.equals("ㅇㅇ") || + message.equals("와") || message.equals("헉")) { + return false; + } + + return message.equals(lastMessage); + } + + private boolean shouldResetBurst(long currentTime) { + return burstStartTime > 0 && (currentTime - burstStartTime) > BURST_RESET_MS; + } + + private void resetBurst(long currentTime) { + burstCount = 0; + burstStartTime = currentTime; + } + + private boolean isInCooldown(long currentTime) { + return lastMessageTime > 0 && (currentTime - lastMessageTime) < COOLDOWN_MS; + } + + private boolean isEmotionalExpression(String message) { + return message.equals("ㅋㅋㅋ") || message.equals("ㅠㅠ") || message.equals("ㅎㅎ") || + message.equals("ㄷㄷ") || message.equals("ㄱㄱ") || message.equals("ㅇㅇ") || + message.equals("와") || message.equals("헉"); + } + + private void recordMessage(String message, long currentTime, boolean countToBurst) { + if (countToBurst && burstCount == 0) { + burstStartTime = currentTime; + } + if (countToBurst) { + burstCount++; + } + lastMessageTime = currentTime; + lastMessage = message; + } + + private void recordMessage(String message, long currentTime) { + recordMessage(message, currentTime, true); + } + + public long getLastActivity() { + return lastActivity; + } + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilterTest.java b/backend/src/test/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilterTest.java new file mode 100644 index 00000000..01a5e39f --- /dev/null +++ b/backend/src/test/java/com/ssafy/pookie/game/chat/filter/BurstAllowedChatFilterTest.java @@ -0,0 +1,268 @@ +package com.ssafy.pookie.game.chat.filter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Nested; + +import static org.junit.jupiter.api.Assertions.*; + +class BurstAllowedChatFilterTest { + + private BurstAllowedChatFilter chatFilter; + private final String TEST_USER_ID = "testUser123"; + private final String ANOTHER_USER_ID = "anotherUser456"; + + @BeforeEach + void setUp() { + chatFilter = new BurstAllowedChatFilter(); + } + + @Nested + @DisplayName("버스트 허용 테스트") + class BurstAllowanceTest { + + @Test + @DisplayName("연속 3개 메시지는 즉시 허용되어야 함") + void shouldAllowFirst3MessagesImmediately() { + // When & Then + assertTrue(chatFilter.canSend(TEST_USER_ID, "첫번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "두번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "세번째")); + } + + @Test + @DisplayName("4번째 메시지부터는 쿨다운 적용되어야 함") + void shouldApplyCooldownAfter3Messages() { + // Given - 3개 메시지 전송 + assertTrue(chatFilter.canSend(TEST_USER_ID, "첫번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "두번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "세번째")); + + // When & Then - 4번째 메시지는 즉시 거부 + assertFalse(chatFilter.canSend(TEST_USER_ID, "네번째")); + } + + @Test + @DisplayName("쿨다운 시간 후에는 다시 전송 가능해야 함") + void shouldAllowMessageAfterCooldown() throws InterruptedException { + // Given - 3개 메시지 전송 후 4번째 시도 + chatFilter.canSend(TEST_USER_ID, "첫번째"); + chatFilter.canSend(TEST_USER_ID, "두번째"); + chatFilter.canSend(TEST_USER_ID, "세번째"); + assertFalse(chatFilter.canSend(TEST_USER_ID, "네번째")); + + // When - 쿨다운 시간 대기 + Thread.sleep(1100); // 1.1초 대기 (쿨다운 1초 + 여유) + + // Then + assertTrue(chatFilter.canSend(TEST_USER_ID, "쿨다운 후")); + } + } + + @Nested + @DisplayName("버스트 리셋 테스트") + class BurstResetTest { + + @Test + @DisplayName("5초 후에는 버스트 카운트가 리셋되어야 함") + void shouldResetBurstCountAfter5Seconds() throws InterruptedException { + // Given - 3개 메시지 전송 + assertTrue(chatFilter.canSend(TEST_USER_ID, "첫번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "두번째")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "세번째")); + + // When - 5초 대기 + Thread.sleep(5100); // 5.1초 대기 + + // Then - 다시 3개 연속 전송 가능 + assertTrue(chatFilter.canSend(TEST_USER_ID, "리셋 후 1")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "리셋 후 2")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "리셋 후 3")); + } + } + + @Nested + @DisplayName("사용자별 독립성 테스트") + class UserIsolationTest { + + @Test + @DisplayName("사용자별로 독립적인 버스트 카운트를 가져야 함") + void shouldHaveIndependentBurstCountPerUser() { + // Given & When - 첫 번째 사용자가 3개 전송 + assertTrue(chatFilter.canSend(TEST_USER_ID, "user1-1")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "user1-2")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "user1-3")); + assertFalse(chatFilter.canSend(TEST_USER_ID, "user1-4")); + + // Then - 두 번째 사용자는 영향받지 않고 3개 전송 가능 + assertTrue(chatFilter.canSend(ANOTHER_USER_ID, "user2-1")); + assertTrue(chatFilter.canSend(ANOTHER_USER_ID, "user2-2")); + assertTrue(chatFilter.canSend(ANOTHER_USER_ID, "user2-3")); + } + } + + @Nested + @DisplayName("스팸 패턴 감지 테스트") + class SpamDetectionTest { + + @Test + @DisplayName("동일 메시지 반복은 차단되어야 함") + void shouldBlockRepeatedIdenticalMessages() { + // Given - 첫 번째 메시지는 허용 + assertTrue(chatFilter.canSend(TEST_USER_ID, "반복메시지")); + + // When & Then - 같은 메시지 반복 시 차단 + assertFalse(chatFilter.canSend(TEST_USER_ID, "반복메시지")); + } + + @Test + @DisplayName("명백한 스팸 패턴은 버스트 중에도 차단되어야 함") + void shouldBlockObviousSpamEvenDuringBurst() { + // When & Then - 스팸성 메시지들 + assertFalse(chatFilter.canSend(TEST_USER_ID, "ㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁ")); + assertFalse(chatFilter.canSend(TEST_USER_ID, "aaaaaaaaaaaa")); + assertFalse(chatFilter.canSend(TEST_USER_ID, "1111111111111")); + } + + @Test + @DisplayName("너무 긴 메시지는 차단되어야 함") + void shouldBlockTooLongMessages() { + // Given + String longMessage = "a".repeat(201); // 200자 초과 + + // When & Then + assertFalse(chatFilter.canSend(TEST_USER_ID, longMessage)); + } + + @Test + @DisplayName("빈 메시지나 공백만 있는 메시지는 차단되어야 함") + void shouldBlockEmptyOrWhitespaceMessages() { + assertFalse(chatFilter.canSend(TEST_USER_ID, "")); + assertFalse(chatFilter.canSend(TEST_USER_ID, " ")); + assertFalse(chatFilter.canSend(TEST_USER_ID, "\t\n")); + } + } + + @Nested + @DisplayName("메시지 타입별 허용 테스트") + class MessageTypeTest { + + @Test + @DisplayName("감정 표현은 허용되어야 함") + void shouldAllowEmotionalExpressions() { + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㅋㅋㅋ")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㅠㅠ")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㅎㅎ")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㄷㄷ")); + } + + @Test + @DisplayName("짧은 반응 메시지는 허용되어야 함") + void shouldAllowShortReactions() { + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㄱㄱ")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "ㅇㅇ")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "와")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "헉")); + } + + @Test + @DisplayName("정상적인 대화는 허용되어야 함") + void shouldAllowNormalConversation() { + assertTrue(chatFilter.canSend(TEST_USER_ID, "안녕하세요")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "게임 시작할까요?")); + assertTrue(chatFilter.canSend(TEST_USER_ID, "좋은 게임이었습니다")); + } + } + + @Nested + @DisplayName("동시성 테스트") + class ConcurrencyTest { + + @Test + @DisplayName("여러 스레드에서 동시 접근해도 안전해야 함") + void shouldBeSafeUnderConcurrentAccess() throws InterruptedException { + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + boolean[] results = new boolean[threadCount]; + + // Given & When + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + threads[i] = new Thread(() -> { + results[threadId] = chatFilter.canSend(TEST_USER_ID, "concurrent-" + threadId); + }); + threads[i].start(); + } + + for (Thread thread : threads) { + thread.join(); + } + + // Then - 처음 3개는 true, 나머지는 false여야 함 + int allowedCount = 0; + for (boolean result : results) { + if (result) allowedCount++; + } + + assertTrue(allowedCount <= 3, "최대 3개의 메시지만 허용되어야 함"); + } + } + + @Nested + @DisplayName("메모리 누수 방지 테스트") + class MemoryLeakTest { + + @Test + @DisplayName("오래된 사용자 데이터는 정리되어야 함") + void shouldCleanupOldUserData() throws InterruptedException { + // Given - 많은 사용자가 메시지 전송 + for (int i = 0; i < 100; i++) { + chatFilter.canSend("user" + i, "message"); + } + + // When - 시간 경과 후 정리 트리거 + Thread.sleep(6000); // 6초 대기 + + // 새로운 사용자가 접근할 때 정리가 일어나야 함 + assertTrue(chatFilter.canSend("newUser", "trigger cleanup")); + + // Then - 실제 메모리 사용량 검증은 어렵지만, 예외 없이 동작하는지 확인 + assertDoesNotThrow(() -> { + for (int i = 0; i < 100; i++) { + chatFilter.canSend("cleanupTest" + i, "message"); + } + }); + } + } + + @Nested + @DisplayName("엣지 케이스 테스트") + class EdgeCaseTest { + + @Test + @DisplayName("null 사용자 ID는 처리되어야 함") + void shouldHandleNullUserId() { + assertFalse(chatFilter.canSend(null, "message")); + } + + @Test + @DisplayName("빈 사용자 ID는 처리되어야 함") + void shouldHandleEmptyUserId() { + assertFalse(chatFilter.canSend("", "message")); + } + + @Test + @DisplayName("null 메시지는 처리되어야 함") + void shouldHandleNullMessage() { + assertFalse(chatFilter.canSend(TEST_USER_ID, null)); + } + + @Test + @DisplayName("매우 긴 사용자 ID도 처리되어야 함") + void shouldHandleVeryLongUserId() { + String longUserId = "user".repeat(100); + assertTrue(chatFilter.canSend(longUserId, "normal message")); + } + } +} \ No newline at end of file