Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ jobs:
- name: Setup Gradle cache
uses: gradle/actions/setup-gradle@v4

- name: Ensure Docker is available (Testcontainers)
run: |
echo ">>> [CI] Checking Docker daemon..."
docker info

- name: Run tests
run: |
echo ">>> [CI] Running tests..."
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,7 @@ pinpoint-agent/tools/
.claude

### env ###
.env
.env

# macOS artefacts
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public record ChatMessageResponse(
String senderNickname,
String content,
String messageType,
LocalDateTime sentAt
LocalDateTime sentAt,
int unreadCount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project.dorumdorum.domain.chat.application.dto.response;

import java.time.LocalDateTime;

public record ChatReadReceiptResponse(
String chatRoomNo,
String readerUserNo,
LocalDateTime readAt
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.project.dorumdorum.domain.chat.application.dto.response.ChatMessageResponse;
import com.project.dorumdorum.domain.chat.domain.entity.ChatMessage;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember;
import com.project.dorumdorum.domain.chat.domain.entity.MessageType;
import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService;
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService;
Expand All @@ -17,6 +18,8 @@
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.time.LocalDateTime;

@Slf4j
@Component
@RequiredArgsConstructor
Expand All @@ -40,17 +43,20 @@ public class RoommateKickedEventListener {
public void handle(RoommateKickedEvent event) {
chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> {
if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) {
chatRoomMemberService.leave(
chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo())
);
ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo());
LocalDateTime fromTime = member.getLastReadAt() != null
? member.getLastReadAt()
: member.getJoinedAt();
chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo());
chatRoomMemberService.leave(member);
User kicked = userService.findById(event.kickedUserNo());
String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank())
? kicked.getNickname() : kicked.getName();
String content = displayName + "가 퇴장했습니다.";
ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0);
ChatMessageResponse response = new ChatMessageResponse(
message.getMessageNo(), chatRoom.getChatRoomNo(),
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt());
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public void handle(RoommateAcceptedEvent event) {
ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0);
ChatMessageResponse response = new ChatMessageResponse(
message.getMessageNo(), chatRoom.getChatRoomNo(),
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt());
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
broadcastSafely(chatRoom, response);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

import static com.project.dorumdorum.global.exception.code.status.ChatErrorStatus.HOST_CANNOT_LEAVE;

@Service
Expand Down Expand Up @@ -61,6 +63,13 @@ private void leaveGroupChatRoom(ChatRoom chatRoom, ChatRoomMember member, String
throw new RestApiException(HOST_CANNOT_LEAVE);
}

if (memberCount > 1) {
LocalDateTime fromTime = member.getLastReadAt() != null
? member.getLastReadAt()
: member.getJoinedAt();
chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, userNo);
}

chatRoomMemberService.leave(member);
roommateService.leaveRoom(userNo, chatRoom.getRoomNo());
room.minusCurrentMate();
Expand All @@ -79,6 +88,13 @@ private void leaveDirectChatRoom(ChatRoom chatRoom, ChatRoomMember member, Strin
throw new RestApiException(HOST_CANNOT_LEAVE);
}

if (memberCount > 1) {
LocalDateTime fromTime = member.getLastReadAt() != null
? member.getLastReadAt()
: member.getJoinedAt();
chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, userNo);
}

chatRoomMemberService.leave(member);

if (memberCount == 1) {
Expand All @@ -99,7 +115,7 @@ private void sendLeaveSystemMessage(ChatRoom chatRoom, String userNo) {
ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0);
ChatMessageResponse response = new ChatMessageResponse(
message.getMessageNo(), chatRoomNo,
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt());
"SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount());
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, response);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.project.dorumdorum.domain.chat.application.usecase;

import com.project.dorumdorum.domain.chat.application.dto.response.ChatReadReceiptResponse;
import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember;
import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService;
import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
Expand All @@ -15,6 +17,7 @@ public class MarkChatRoomReadUseCase {

private final ChatRoomMemberService chatRoomMemberService;
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;

/**
* 채팅방 읽음 처리
Expand All @@ -31,6 +34,12 @@ public void execute(String chatRoomNo, String userNo) {
: member.getJoinedAt();

chatMessageService.decreaseUnreadCount(chatRoomNo, fromTime, userNo);
chatRoomMemberService.updateLastReadAt(member, LocalDateTime.now());
LocalDateTime readAt = LocalDateTime.now();
chatRoomMemberService.updateLastReadAt(member, readAt);

messagingTemplate.convertAndSend(
"/topic/chat-room/" + chatRoomNo + "/read",
new ChatReadReceiptResponse(chatRoomNo, userNo, readAt)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ public void send(String chatRoomNo, String senderNo, String content) {
senderNickname,
content,
MessageType.TEXT.name(),
message.getCreatedAt()
message.getCreatedAt(),
message.getUnreadCount()
);
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, response);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public void leaveRoom(String userNo, String roomNo) {
Roommate roommate = roommateRepository.findByUserNoAndRoomNo(userNo, roomNo)
.orElseThrow(() -> new RestApiException(_NOT_FOUND));
roommateRepository.delete(roommate);
roommateRepository.flush();
}

public List<MyRoommateResponse> findMyRoommates(String userNo) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
spring:
devtools:
restart:
enabled: false
datasource:
username: ${RDB_USERNAME:postgres}
url: ${RDB_URL:jdbc:postgresql://localhost:5432/dorumdorum}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.FirebaseMessaging;
import com.project.dorumdorum.testsupport.TestcontainersSupport;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -19,6 +22,14 @@
@DisplayName("ChatRoom schema 초기화 테스트")
class ChatRoomSchemaInitializationTest {

@BeforeAll
static void requireDocker() {
Assumptions.assumeTrue(
TestcontainersSupport.requireDockerOrSkip("ChatRoomSchemaInitializationTest"),
"Docker is required for ChatRoomSchemaInitializationTest"
);
}
Comment on lines +25 to +31

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SpringBootTest는 JUnit @BeforeAll보다 먼저 Spring 컨텍스트를 초기화하므로, 이 Assumptions.assumeTrue(...)로는 Docker 없는 환경에서 Testcontainers datasource 초기화 실패를 방지할 수 없습니다. Docker 필요 여부를 컨텍스트 로딩 이전에 평가되는 방식(예: JUnit5 ExecutionCondition 확장/조건부 실행 애너테이션, 또는 @Testcontainers(disabledWithoutDocker = true) 등)으로 전환해 테스트 클래스 자체가 스킵되도록 해주세요.

Copilot uses AI. Check for mistakes.

@Autowired
private JdbcTemplate jdbcTemplate;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@
import com.project.dorumdorum.domain.user.domain.entity.Role;
import com.project.dorumdorum.domain.user.domain.entity.User;
import com.project.dorumdorum.domain.user.domain.service.UserService;
import com.project.dorumdorum.testsupport.TestcontainersSupport;
import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.FirebaseMessaging;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.context.ActiveProfiles;

import java.time.LocalDateTime;
Expand Down Expand Up @@ -64,9 +67,17 @@
@DisplayName("채팅 트랜잭션 원자성 통합 테스트 (BEFORE_COMMIT)")
class ChatTransactionAtomicityIntegrationTest {

@BeforeAll
static void requireDocker() {
Assumptions.assumeTrue(
TestcontainersSupport.requireDockerOrSkip("ChatTransactionAtomicityIntegrationTest"),
"Docker is required for ChatTransactionAtomicityIntegrationTest"
);
}
Comment on lines +70 to +76

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SpringBootTest 컨텍스트가 JUnit @BeforeAll 실행 전에 먼저 로드되기 때문에, 여기의 Assumptions.assumeTrue(...)는 Docker 없는 환경에서 Testcontainers JDBC 드라이버가 컨텍스트 초기화 중 컨테이너를 띄우다 실패하는 것을 막지 못합니다. Docker 감지는 클래스 실행 전에 평가되는 조건(예: JUnit5 ExecutionCondition 확장, 또는 Testcontainers JUnit의 @Testcontainers(disabledWithoutDocker = true)/Spring test의 조건부 실행 애너테이션)으로 옮겨서 컨텍스트 자체가 로드되지 않도록 해주세요.

Copilot uses AI. Check for mistakes.

// ─── Firebase 목 (FileInputStream 오류 방지 — 테스트에서 FCM 사용 안 함) ──
@MockBean @SuppressWarnings("unused") private FirebaseApp firebaseApp;
@MockBean @SuppressWarnings("unused") private FirebaseMessaging firebaseMessaging;
@MockitoBean @SuppressWarnings("unused") private FirebaseApp firebaseApp;
@MockitoBean @SuppressWarnings("unused") private FirebaseMessaging firebaseMessaging;

// ─── UseCase ──────────────────────────────────────────────────────────────
@Autowired private KickRoommateUseCase kickRoommateUseCase;
Expand All @@ -78,12 +89,12 @@ class ChatTransactionAtomicityIntegrationTest {
@Autowired private ChatRoomMemberRepository chatRoomMemberRepository;

// ─── External Services (Mocking to isolate behavior) ─────────────────────
@MockBean private SimpMessagingTemplate messagingTemplate;
@MockitoBean private SimpMessagingTemplate messagingTemplate;

// ─── SpyBean (특정 메서드만 실패 강제 또는 외부 의존 우회) ─────────────
@SpyBean private ChatRoomMemberService chatRoomMemberService;
@SpyBean private UserService userService;
@SpyBean private ChatMessageService chatMessageService;
@MockitoSpyBean private ChatRoomMemberService chatRoomMemberService;
@MockitoSpyBean private UserService userService;
@MockitoSpyBean private ChatMessageService chatMessageService;

// ─── 테스트 픽스처 ────────────────────────────────────────────────────────
private Room room;
Expand Down
Loading
Loading