From bb6ca5e0fec14ac4244a46dbd60f194fca9b0c85 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Thu, 14 Sep 2023 18:28:30 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=EA=B0=84=EB=8B=A8=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B2=B4=ED=8C=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/websocket/demo/WebSocketConfig.java | 24 ++++++++ .../demo/controller/ChatController.java | 24 ++++++++ .../websocket/demo/request/ChatRequest.java | 8 +++ .../com/websocket/demo/response/ChatInfo.java | 9 +++ src/main/resources/static/app.js | 61 +++++++++++++++++++ src/main/resources/static/index.html | 57 +++++++++++++++++ src/main/resources/static/main.css | 14 +++++ 7 files changed, 197 insertions(+) create mode 100644 src/main/java/com/websocket/demo/WebSocketConfig.java create mode 100644 src/main/java/com/websocket/demo/controller/ChatController.java create mode 100644 src/main/java/com/websocket/demo/request/ChatRequest.java create mode 100644 src/main/java/com/websocket/demo/response/ChatInfo.java create mode 100644 src/main/resources/static/app.js create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/main.css diff --git a/src/main/java/com/websocket/demo/WebSocketConfig.java b/src/main/java/com/websocket/demo/WebSocketConfig.java new file mode 100644 index 0000000..2346833 --- /dev/null +++ b/src/main/java/com/websocket/demo/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.websocket.demo; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/chatting"); + } + +} diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java new file mode 100644 index 0000000..4f8586b --- /dev/null +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -0,0 +1,24 @@ +package com.websocket.demo.controller; + +import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.response.ChatInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; +import org.springframework.web.socket.WebSocketSession; + +@Slf4j +@Controller +public class ChatController { + + @MessageMapping("/chat") + @SendTo("/topic/chat") + public ChatInfo greeting(ChatRequest request){ + var chatInfo = new ChatInfo(); + chatInfo.setMessage(request.getMessage()); +// chatInfo.setSender(userInfo.getNickname()); + return chatInfo; + } + +} diff --git a/src/main/java/com/websocket/demo/request/ChatRequest.java b/src/main/java/com/websocket/demo/request/ChatRequest.java new file mode 100644 index 0000000..3823783 --- /dev/null +++ b/src/main/java/com/websocket/demo/request/ChatRequest.java @@ -0,0 +1,8 @@ +package com.websocket.demo.request; + +import lombok.Data; + +@Data +public class ChatRequest { + private String message; +} diff --git a/src/main/java/com/websocket/demo/response/ChatInfo.java b/src/main/java/com/websocket/demo/response/ChatInfo.java new file mode 100644 index 0000000..d8c027c --- /dev/null +++ b/src/main/java/com/websocket/demo/response/ChatInfo.java @@ -0,0 +1,9 @@ +package com.websocket.demo.response; + +import lombok.Data; + +@Data +public class ChatInfo { + private String sender; + private String message; +} diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..14613ad --- /dev/null +++ b/src/main/resources/static/app.js @@ -0,0 +1,61 @@ +const stompClient = new StompJs.Client({ + brokerURL: 'ws://localhost:8080/chatting' +}); + +stompClient.onConnect = (frame) => { + setConnected(true); + console.log('Connected: ' + frame); + stompClient.subscribe('/topic/chat', (greeting) => { + const response = JSON.parse(greeting.body); + showGreeting(response.sender + " : " + response.message); + }); +}; + +stompClient.onWebSocketError = (error) => { + console.error('Error with websocket', error); +}; + +stompClient.onStompError = (frame) => { + console.error('Broker reported error: ' + frame.headers['message']); + console.error('Additional details: ' + frame.body); +}; + +function setConnected(connected) { + $("#connect").prop("disabled", connected); + $("#disconnect").prop("disabled", !connected); + if (connected) { + $("#conversation").show(); + } + else { + $("#conversation").hide(); + } + $("#greetings").html(""); +} + +function connect() { + stompClient.activate(); +} + +function disconnect() { + stompClient.deactivate(); + setConnected(false); + console.log("Disconnected"); +} + +function sendName() { + stompClient.publish({ + destination: "/app/chat", + body: JSON.stringify({'message': $("#name").val()}) + }); +} + +function showGreeting(message) { + $("#greetings").append("" + message + ""); +} + +$(function () { + $("form").on('submit', (e) => e.preventDefault()); + $( "#connect" ).click(() => connect()); + $( "#disconnect" ).click(() => disconnect()); + $( "#send" ).click(() => sendName()); +}); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..efb962a --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,57 @@ + + + + Hello WebSocket + + + + + + + +
+ 회원가입 + 로그인 + 친구추가 +
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+ + +
+ +
+
+
+
+
+ + + + + + + + +
Greetings
+
+
+
+ + diff --git a/src/main/resources/static/main.css b/src/main/resources/static/main.css new file mode 100644 index 0000000..8643b76 --- /dev/null +++ b/src/main/resources/static/main.css @@ -0,0 +1,14 @@ +body { + background-color: #f5f5f5; +} + +#main-content { + max-width: 940px; + padding: 2em 3em; + margin: 0 auto 20px; + background-color: #fff; + border: 1px solid #e5e5e5; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} \ No newline at end of file From 6a10ef18f6ef54f5ef25c979d9aed714224c159f Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Fri, 15 Sep 2023 11:29:51 +0900 Subject: [PATCH 02/14] =?UTF-8?q?=EC=B2=B4=ED=8C=85=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=A0=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/websocket/demo/WebSocketConfig.java | 25 ++-- .../demo/controller/ChatController.java | 8 +- .../interceptor/ChatHandshakeInterceptor.java | 30 +++++ .../websocket/demo/request/ChatRequest.java | 2 + .../java/com/websocket/demo/SpringTest.java | 2 +- .../websocket/demo/integrate/ChatTest.java | 124 ++++++++++++++++++ .../ChatHandshakeInterceptorTest.java | 53 ++++++++ 7 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java create mode 100644 src/test/java/com/websocket/demo/integrate/ChatTest.java create mode 100644 src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java diff --git a/src/main/java/com/websocket/demo/WebSocketConfig.java b/src/main/java/com/websocket/demo/WebSocketConfig.java index 2346833..3a24363 100644 --- a/src/main/java/com/websocket/demo/WebSocketConfig.java +++ b/src/main/java/com/websocket/demo/WebSocketConfig.java @@ -1,24 +1,31 @@ package com.websocket.demo; +import com.websocket.demo.interceptor.ChatHandshakeInterceptor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +@Slf4j @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - @Override - public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic"); - config.setApplicationDestinationPrefixes("/app"); - } + @Autowired + ChatHandshakeInterceptor chatHandshakeInterceptor; + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/chatting"); - } + @Override + public void registerStompEndpoints(StompEndpointRegistry registry){ + registry.addEndpoint("/chatting") + .addInterceptors(chatHandshakeInterceptor); + } } diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java index 4f8586b..2009ccd 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -6,7 +6,6 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.stereotype.Controller; -import org.springframework.web.socket.WebSocketSession; @Slf4j @Controller @@ -14,11 +13,10 @@ public class ChatController { @MessageMapping("/chat") @SendTo("/topic/chat") - public ChatInfo greeting(ChatRequest request){ + public ChatInfo chatHandle(ChatRequest request){ var chatInfo = new ChatInfo(); chatInfo.setMessage(request.getMessage()); -// chatInfo.setSender(userInfo.getNickname()); + chatInfo.setSender(request.getSender()); return chatInfo; } - -} +} \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java new file mode 100644 index 0000000..b7870a3 --- /dev/null +++ b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java @@ -0,0 +1,30 @@ +package com.websocket.demo.interceptor; + +import com.websocket.demo.request.LoginRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Component +public class ChatHandshakeInterceptor implements HandshakeInterceptor { + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) { + return isLogin((ServletServerHttpRequest) request); + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { + } + + private static boolean isLogin(ServletServerHttpRequest request) { + var servletRequest = request; + var session = servletRequest.getServletRequest().getSession(); + var info = (LoginRequest) session.getAttribute("user"); + return info != null; + } +} diff --git a/src/main/java/com/websocket/demo/request/ChatRequest.java b/src/main/java/com/websocket/demo/request/ChatRequest.java index 3823783..bab8f62 100644 --- a/src/main/java/com/websocket/demo/request/ChatRequest.java +++ b/src/main/java/com/websocket/demo/request/ChatRequest.java @@ -4,5 +4,7 @@ @Data public class ChatRequest { + + private String sender; private String message; } diff --git a/src/test/java/com/websocket/demo/SpringTest.java b/src/test/java/com/websocket/demo/SpringTest.java index 6f58196..7ed0269 100644 --- a/src/test/java/com/websocket/demo/SpringTest.java +++ b/src/test/java/com/websocket/demo/SpringTest.java @@ -3,7 +3,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") public class SpringTest { diff --git a/src/test/java/com/websocket/demo/integrate/ChatTest.java b/src/test/java/com/websocket/demo/integrate/ChatTest.java new file mode 100644 index 0000000..eff7479 --- /dev/null +++ b/src/test/java/com/websocket/demo/integrate/ChatTest.java @@ -0,0 +1,124 @@ +package com.websocket.demo.integrate; + +import com.websocket.demo.SpringTest; +import com.websocket.demo.interceptor.ChatHandshakeInterceptor; +import com.websocket.demo.request.ChatRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.*; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import java.lang.reflect.Type; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +public class ChatTest extends SpringTest { + + @Value(value = "${local.server.port}") + private int port; + @MockBean + ChatHandshakeInterceptor chatHandshakeInterceptor; + + private WebSocketStompClient stompClient; + + private final WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); + + @BeforeEach + public void setup() { + WebSocketClient webSocketClient = new StandardWebSocketClient(); + this.stompClient = new WebSocketStompClient(webSocketClient); + this.stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + } + + @Test + public void getGreeting() throws Exception { + + given(chatHandshakeInterceptor.beforeHandshake(any(), any(), any(), any())) + .willReturn(true); + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference<>(); + StompSessionHandler handler = new TestSessionHandler(failure) { + + @Override + public void afterConnected(final StompSession session, StompHeaders connectedHeaders) { + session.subscribe("/topic/chat", new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return ChatRequest.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + var chatRequest = (ChatRequest) payload; + try { + assertThat("hello, spring!").isEqualTo(chatRequest.getMessage()); + assertThat("john").isEqualTo(chatRequest.getSender()); + } catch (Throwable t) { + failure.set(t); + } finally { + session.disconnect(); + latch.countDown(); + } + } + }); + try { + var request = new ChatRequest(); + request.setMessage("hello, spring!"); + request.setSender("john"); + session.send("/app/chat", request); + } catch (Throwable t) { + failure.set(t); + latch.countDown(); + } + } + }; + + this.stompClient.connectAsync("ws://localhost:{port}/chatting", this.headers, handler, this.port); + + if (latch.await(3, TimeUnit.SECONDS)) { + if (failure.get() != null) { + throw new AssertionError("", failure.get()); + } + } else { + fail("not received"); + } + + } + + private static class TestSessionHandler extends StompSessionHandlerAdapter { + + private final AtomicReference failure; + + public TestSessionHandler(AtomicReference failure) { + this.failure = failure; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + this.failure.set(new Exception(headers.toString())); + } + + @Override + public void handleException(StompSession s, StompCommand c, StompHeaders h, byte[] p, Throwable ex) { + this.failure.set(ex); + } + + @Override + public void handleTransportError(StompSession session, Throwable ex) { + this.failure.set(ex); + } + } +} diff --git a/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java b/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java new file mode 100644 index 0000000..1fd3b0b --- /dev/null +++ b/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java @@ -0,0 +1,53 @@ +package com.websocket.demo.interceptor; + +import com.websocket.demo.request.LoginRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.server.ServletServerHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +@ExtendWith(MockitoExtension.class) +class ChatHandshakeInterceptorTest { + + @DisplayName("로그인이 된 상태에서 websocket 연결이 진행하면 닉네임 정보를 attribute 에 저장한다.") + @Test + public void beforeHandshakeWhenLogin() { + //given + var loginInfo = new LoginRequest(); + loginInfo.setNickname("hello"); + loginInfo.setPassword("1234"); + var request = mock(ServletServerHttpRequest.class); + var servletRequest = mock(HttpServletRequest.class); + var session = mock(HttpSession.class); + given(request.getServletRequest()).willReturn(servletRequest); + given(servletRequest.getSession()).willReturn(session); + given(session.getAttribute("user")).willReturn(loginInfo); + + //when + boolean isSuccess = new ChatHandshakeInterceptor().beforeHandshake(request, null, null, null); + //then + assertThat(isSuccess).isTrue(); + } + + @DisplayName("로그인 상태가 아니라면 false를 반환하여 이후 절차를 진행하지 않는다.") + @Test + public void beforeHandshakeWhenNotLogin() { + //given + var request = mock(ServletServerHttpRequest.class); + var servletRequest = mock(HttpServletRequest.class); + given(request.getServletRequest()).willReturn(servletRequest); + given(servletRequest.getSession()).willReturn(mock(HttpSession.class)); + //when + boolean isSuccess = new ChatHandshakeInterceptor().beforeHandshake(request, null, null, null); + //then + assertThat(isSuccess).isFalse(); + } + +} \ No newline at end of file From ab689adfc2f3c5aad4ab3a83173ee2b490478850 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Fri, 15 Sep 2023 12:30:17 +0900 Subject: [PATCH 03/14] =?UTF-8?q?=EC=B2=B4=ED=8C=85=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=97=B0=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/websocket/demo/ChatApplication.java | 2 + .../demo/controller/ChatController.java | 18 +++--- .../java/com/websocket/demo/domain/Chat.java | 36 ++++++++++++ .../java/com/websocket/demo/domain/Room.java | 13 +++++ .../demo/repository/ChatRepository.java | 7 +++ .../websocket/demo/request/ChatRequest.java | 1 + .../com/websocket/demo/response/ChatInfo.java | 12 +++- .../websocket/demo/service/ChatService.java | 27 +++++++++ src/main/resources/static/app.js | 4 +- .../websocket/demo/integrate/ChatTest.java | 6 +- .../demo/repository/ChatRepositoryTest.java | 32 ++++++++++ .../demo/service/ChatServiceTest.java | 58 +++++++++++++++++++ 12 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/websocket/demo/domain/Chat.java create mode 100644 src/main/java/com/websocket/demo/domain/Room.java create mode 100644 src/main/java/com/websocket/demo/repository/ChatRepository.java create mode 100644 src/main/java/com/websocket/demo/service/ChatService.java create mode 100644 src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java create mode 100644 src/test/java/com/websocket/demo/service/ChatServiceTest.java diff --git a/src/main/java/com/websocket/demo/ChatApplication.java b/src/main/java/com/websocket/demo/ChatApplication.java index 7949fff..5748286 100644 --- a/src/main/java/com/websocket/demo/ChatApplication.java +++ b/src/main/java/com/websocket/demo/ChatApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class ChatApplication { public static void main(String[] args) { diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java index 2009ccd..50154b8 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -2,21 +2,21 @@ import com.websocket.demo.request.ChatRequest; import com.websocket.demo.response.ChatInfo; -import lombok.extern.slf4j.Slf4j; +import com.websocket.demo.service.ChatService; +import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.stereotype.Controller; -@Slf4j @Controller +@RequiredArgsConstructor public class ChatController { - @MessageMapping("/chat") - @SendTo("/topic/chat") - public ChatInfo chatHandle(ChatRequest request){ - var chatInfo = new ChatInfo(); - chatInfo.setMessage(request.getMessage()); - chatInfo.setSender(request.getSender()); - return chatInfo; + private final ChatService chatService; + + @MessageMapping("/chat/new") + @SendTo("/topic/chat/new") + public ChatInfo newChat(ChatRequest request){ + return chatService.createChat(request); } } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/domain/Chat.java b/src/main/java/com/websocket/demo/domain/Chat.java new file mode 100644 index 0000000..ffc629a --- /dev/null +++ b/src/main/java/com/websocket/demo/domain/Chat.java @@ -0,0 +1,36 @@ +package com.websocket.demo.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Chat { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @Column(nullable = false, updatable = false) + private Long roomId; + @Column(nullable = false, updatable = false) + private String senderNickname; + @Column + private String message; + @CreatedDate + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime createAt; + + @Builder + private Chat(Long roomId, String senderNickname, String message) { + this.roomId = roomId; + this.senderNickname = senderNickname; + this.message = message; + } +} diff --git a/src/main/java/com/websocket/demo/domain/Room.java b/src/main/java/com/websocket/demo/domain/Room.java new file mode 100644 index 0000000..c433fd1 --- /dev/null +++ b/src/main/java/com/websocket/demo/domain/Room.java @@ -0,0 +1,13 @@ +package com.websocket.demo.domain; + +import jakarta.persistence.*; + +@Entity +public class Room { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @Column + private String title; +} diff --git a/src/main/java/com/websocket/demo/repository/ChatRepository.java b/src/main/java/com/websocket/demo/repository/ChatRepository.java new file mode 100644 index 0000000..7e9fd9d --- /dev/null +++ b/src/main/java/com/websocket/demo/repository/ChatRepository.java @@ -0,0 +1,7 @@ +package com.websocket.demo.repository; + +import com.websocket.demo.domain.Chat; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatRepository extends JpaRepository { +} diff --git a/src/main/java/com/websocket/demo/request/ChatRequest.java b/src/main/java/com/websocket/demo/request/ChatRequest.java index bab8f62..a4ae3b8 100644 --- a/src/main/java/com/websocket/demo/request/ChatRequest.java +++ b/src/main/java/com/websocket/demo/request/ChatRequest.java @@ -7,4 +7,5 @@ public class ChatRequest { private String sender; private String message; + private Long roomId; } diff --git a/src/main/java/com/websocket/demo/response/ChatInfo.java b/src/main/java/com/websocket/demo/response/ChatInfo.java index d8c027c..a2177b2 100644 --- a/src/main/java/com/websocket/demo/response/ChatInfo.java +++ b/src/main/java/com/websocket/demo/response/ChatInfo.java @@ -1,9 +1,19 @@ package com.websocket.demo.response; +import com.websocket.demo.domain.Chat; import lombok.Data; @Data public class ChatInfo { private String sender; private String message; -} + private Long roomId; + + public static ChatInfo from(Chat chat){ + var chatInfo = new ChatInfo(); + chatInfo.setMessage(chat.getMessage()); + chatInfo.setSender(chat.getSenderNickname()); + chatInfo.setRoomId(chat.getRoomId()); + return chatInfo; + } +} \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java new file mode 100644 index 0000000..c933fb9 --- /dev/null +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -0,0 +1,27 @@ +package com.websocket.demo.service; + +import com.websocket.demo.domain.Chat; +import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.response.ChatInfo; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class ChatService { + + private final ChatRepository chatRepository; + + public ChatInfo createChat(ChatRequest request) { + var chat = chatRepository.save(Chat.builder() + .senderNickname(request.getSender()) + .roomId(request.getRoomId()) + .message(request.getMessage()) + .build() + ); + return ChatInfo.from(chat); + } +} diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 14613ad..1589b02 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -5,7 +5,7 @@ const stompClient = new StompJs.Client({ stompClient.onConnect = (frame) => { setConnected(true); console.log('Connected: ' + frame); - stompClient.subscribe('/topic/chat', (greeting) => { + stompClient.subscribe('/topic/chat/new', (greeting) => { const response = JSON.parse(greeting.body); showGreeting(response.sender + " : " + response.message); }); @@ -44,7 +44,7 @@ function disconnect() { function sendName() { stompClient.publish({ - destination: "/app/chat", + destination: "/app/chat/new", body: JSON.stringify({'message': $("#name").val()}) }); } diff --git a/src/test/java/com/websocket/demo/integrate/ChatTest.java b/src/test/java/com/websocket/demo/integrate/ChatTest.java index eff7479..e2f43ca 100644 --- a/src/test/java/com/websocket/demo/integrate/ChatTest.java +++ b/src/test/java/com/websocket/demo/integrate/ChatTest.java @@ -54,7 +54,7 @@ public void getGreeting() throws Exception { @Override public void afterConnected(final StompSession session, StompHeaders connectedHeaders) { - session.subscribe("/topic/chat", new StompFrameHandler() { + session.subscribe("/topic/chat/new", new StompFrameHandler() { @Override public Type getPayloadType(StompHeaders headers) { return ChatRequest.class; @@ -66,6 +66,7 @@ public void handleFrame(StompHeaders headers, Object payload) { try { assertThat("hello, spring!").isEqualTo(chatRequest.getMessage()); assertThat("john").isEqualTo(chatRequest.getSender()); + assertThat(100L).isEqualTo(chatRequest.getRoomId()); } catch (Throwable t) { failure.set(t); } finally { @@ -78,7 +79,8 @@ public void handleFrame(StompHeaders headers, Object payload) { var request = new ChatRequest(); request.setMessage("hello, spring!"); request.setSender("john"); - session.send("/app/chat", request); + request.setRoomId(100L); + session.send("/app/chat/new", request); } catch (Throwable t) { failure.set(t); latch.countDown(); diff --git a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java new file mode 100644 index 0000000..9958406 --- /dev/null +++ b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java @@ -0,0 +1,32 @@ +package com.websocket.demo.repository; + +import com.websocket.demo.SpringTest; +import com.websocket.demo.domain.Chat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatRepositoryTest extends SpringTest { + + @Autowired + ChatRepository chatRepository; + @DisplayName("체팅이 저장된다.") + @Test + public void saveChat() { + //given + Chat chat = Chat.builder() + .roomId(100L) + .senderNickname("hello") + .message("hi") + .build(); + //when + Chat saved = chatRepository.save(chat); + //then + assertThat(saved).isNotNull() + .extracting("roomId", "senderNickname", "message") + .containsExactly(100L, "hello", "hi"); + assertThat(saved.getId()).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java new file mode 100644 index 0000000..df4baa6 --- /dev/null +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -0,0 +1,58 @@ +package com.websocket.demo.service; + +import com.websocket.demo.domain.Chat; +import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.response.ChatInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class ChatServiceTest { + + ChatService chatService; + ChatRepository chatRepository; + + @BeforeEach + void init() { + chatRepository = mock(ChatRepository.class); + chatService = new ChatService(chatRepository); + } + + @DisplayName("체팅을 전달 받으면 저장하고 저장된 체팅 정보를 반환한다.") + @Test + public void createChatTest() { + //given + var request = new ChatRequest(); + request.setSender("hello"); + request.setMessage("welcome to the chat service"); + request.setRoomId(100L); + + given(chatRepository.save(any())) + .willAnswer(invocation -> invocation.getArgument(0, Chat.class)); + //when + ChatInfo info = chatService.createChat(request); + //then + assertThat(info).extracting("sender", "message", "roomId") + .containsExactly("hello", "welcome to the chat service", 100L); + } + + @DisplayName("체팅을 전달 받고 저장이 실패하면 오류가 발생한다.") + @Test + public void createChatFail() { + //given + var request = new ChatRequest(); + request.setSender("hello"); + request.setMessage("welcome to the chat service"); + + given(chatRepository.save(any())).willThrow(RuntimeException.class); + //when //then + assertThatThrownBy(() -> chatService.createChat(request)); + } +} \ No newline at end of file From eaaee97d5a0a487acfbc3a12d49d99c91f930d65 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Fri, 15 Sep 2023 16:16:04 +0900 Subject: [PATCH 04/14] =?UTF-8?q?=EC=B2=B4=ED=8C=85=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/controller/ChatController.java | 20 ++++++- .../java/com/websocket/demo/domain/Chat.java | 2 + .../interceptor/ChatHandshakeInterceptor.java | 3 + ...hatRequest.java => CreateChatRequest.java} | 2 +- .../demo/request/DeleteChatRequest.java | 9 +++ .../com/websocket/demo/response/ChatInfo.java | 2 + .../websocket/demo/response/DeleteChat.java | 11 ++++ .../websocket/demo/service/ChatService.java | 15 +++-- src/main/resources/static/app.js | 31 +++++++++- src/main/resources/static/index.html | 9 ++- .../websocket/demo/integrate/ChatTest.java | 19 +++--- .../demo/repository/ChatRepositoryTest.java | 16 +++++ .../websocket/demo/response/ChatInfoTest.java | 29 +++++++++ .../demo/service/ChatServiceTest.java | 60 ++++++++++++------- 14 files changed, 183 insertions(+), 45 deletions(-) rename src/main/java/com/websocket/demo/request/{ChatRequest.java => CreateChatRequest.java} (81%) create mode 100644 src/main/java/com/websocket/demo/request/DeleteChatRequest.java create mode 100644 src/main/java/com/websocket/demo/response/DeleteChat.java create mode 100644 src/test/java/com/websocket/demo/response/ChatInfoTest.java diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java index 50154b8..ed9e72b 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -1,14 +1,21 @@ package com.websocket.demo.controller; -import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.DeleteChatRequest; import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.response.DeleteChat; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; @Controller +@RequestMapping @RequiredArgsConstructor public class ChatController { @@ -16,7 +23,14 @@ public class ChatController { @MessageMapping("/chat/new") @SendTo("/topic/chat/new") - public ChatInfo newChat(ChatRequest request){ - return chatService.createChat(request); + public ChatInfo newChat(CreateChatRequest request) { + return chatService.create(request); } + + @MessageMapping("/chat/delete") + @SendTo("/topic/chat/delete") + public DeleteChat deleteChat(DeleteChatRequest request) { + return chatService.delete(request); + } + } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/domain/Chat.java b/src/main/java/com/websocket/demo/domain/Chat.java index ffc629a..9b672ee 100644 --- a/src/main/java/com/websocket/demo/domain/Chat.java +++ b/src/main/java/com/websocket/demo/domain/Chat.java @@ -6,12 +6,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) public class Chat { @Id diff --git a/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java index b7870a3..e62f554 100644 --- a/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java +++ b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java @@ -1,6 +1,7 @@ package com.websocket.demo.interceptor; import com.websocket.demo.request.LoginRequest; +import lombok.RequiredArgsConstructor; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServletServerHttpRequest; @@ -11,7 +12,9 @@ import java.util.Map; @Component +@RequiredArgsConstructor public class ChatHandshakeInterceptor implements HandshakeInterceptor { + @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) { return isLogin((ServletServerHttpRequest) request); diff --git a/src/main/java/com/websocket/demo/request/ChatRequest.java b/src/main/java/com/websocket/demo/request/CreateChatRequest.java similarity index 81% rename from src/main/java/com/websocket/demo/request/ChatRequest.java rename to src/main/java/com/websocket/demo/request/CreateChatRequest.java index a4ae3b8..c19ef4c 100644 --- a/src/main/java/com/websocket/demo/request/ChatRequest.java +++ b/src/main/java/com/websocket/demo/request/CreateChatRequest.java @@ -3,7 +3,7 @@ import lombok.Data; @Data -public class ChatRequest { +public class CreateChatRequest { private String sender; private String message; diff --git a/src/main/java/com/websocket/demo/request/DeleteChatRequest.java b/src/main/java/com/websocket/demo/request/DeleteChatRequest.java new file mode 100644 index 0000000..9b45cac --- /dev/null +++ b/src/main/java/com/websocket/demo/request/DeleteChatRequest.java @@ -0,0 +1,9 @@ +package com.websocket.demo.request; + +import lombok.Data; + +@Data +public class DeleteChatRequest { + private Long roomId; + private Long id; +} diff --git a/src/main/java/com/websocket/demo/response/ChatInfo.java b/src/main/java/com/websocket/demo/response/ChatInfo.java index a2177b2..0943efc 100644 --- a/src/main/java/com/websocket/demo/response/ChatInfo.java +++ b/src/main/java/com/websocket/demo/response/ChatInfo.java @@ -5,12 +5,14 @@ @Data public class ChatInfo { + private Long id; private String sender; private String message; private Long roomId; public static ChatInfo from(Chat chat){ var chatInfo = new ChatInfo(); + chatInfo.setId(chat.getId()); chatInfo.setMessage(chat.getMessage()); chatInfo.setSender(chat.getSenderNickname()); chatInfo.setRoomId(chat.getRoomId()); diff --git a/src/main/java/com/websocket/demo/response/DeleteChat.java b/src/main/java/com/websocket/demo/response/DeleteChat.java new file mode 100644 index 0000000..25afb54 --- /dev/null +++ b/src/main/java/com/websocket/demo/response/DeleteChat.java @@ -0,0 +1,11 @@ +package com.websocket.demo.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DeleteChat { + private Long roomId; + private Long id; +} diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index c933fb9..8f4a3b4 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -2,21 +2,23 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.repository.ChatRepository; -import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.DeleteChatRequest; import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.response.DeleteChat; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service -@Transactional @RequiredArgsConstructor +@Transactional public class ChatService { private final ChatRepository chatRepository; - public ChatInfo createChat(ChatRequest request) { - var chat = chatRepository.save(Chat.builder() + public ChatInfo create(CreateChatRequest request) { + var chat = chatRepository.saveAndFlush(Chat.builder() .senderNickname(request.getSender()) .roomId(request.getRoomId()) .message(request.getMessage()) @@ -24,4 +26,9 @@ public ChatInfo createChat(ChatRequest request) { ); return ChatInfo.from(chat); } + + public DeleteChat delete(DeleteChatRequest request) { + chatRepository.deleteById(request.getId()); + return new DeleteChat(request.getRoomId(), request.getId()); + } } diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 1589b02..6515874 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -7,7 +7,19 @@ stompClient.onConnect = (frame) => { console.log('Connected: ' + frame); stompClient.subscribe('/topic/chat/new', (greeting) => { const response = JSON.parse(greeting.body); - showGreeting(response.sender + " : " + response.message); + const sender = response.sender; + const message = response.message; + const roomId = response.roomId; + const id = response.id; + console.log(response); + showGreeting("NEW room : " + roomId + ", from : " + sender + "=(" + id + ": " + message + ")"); + }); + stompClient.subscribe('/topic/chat/delete', (greeting) => { + const response = JSON.parse(greeting.body); + const status = response.status; + const id = response.id; + const roomId = response.roomId; + showGreeting("DELETE " + id + " at " + roomId); }); }; @@ -45,7 +57,21 @@ function disconnect() { function sendName() { stompClient.publish({ destination: "/app/chat/new", - body: JSON.stringify({'message': $("#name").val()}) + body: JSON.stringify({ + 'message': $("#name").val(), + 'sender': "test1", + 'roomId': 10 + }) + }); +} + +function deleteChat() { + stompClient.publish({ + destination: "/app/chat/delete", + body: JSON.stringify({ + 'id': $("#id").val(), + 'roomId': 10 + }) }); } @@ -58,4 +84,5 @@ $(function () { $( "#connect" ).click(() => connect()); $( "#disconnect" ).click(() => disconnect()); $( "#send" ).click(() => sendName()); + $( "#deleteId" ).click(() => deleteChat()); }); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index efb962a..18dd4db 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -33,10 +33,17 @@
- +
+
+
+ + +
+ +
diff --git a/src/test/java/com/websocket/demo/integrate/ChatTest.java b/src/test/java/com/websocket/demo/integrate/ChatTest.java index e2f43ca..d928475 100644 --- a/src/test/java/com/websocket/demo/integrate/ChatTest.java +++ b/src/test/java/com/websocket/demo/integrate/ChatTest.java @@ -2,10 +2,10 @@ import com.websocket.demo.SpringTest; import com.websocket.demo.interceptor.ChatHandshakeInterceptor; -import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.request.CreateChatRequest; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.BDDMockito; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.messaging.converter.MappingJackson2MessageConverter; @@ -43,6 +43,7 @@ public void setup() { this.stompClient.setMessageConverter(new MappingJackson2MessageConverter()); } + @DisplayName("체팅 요청을 보내면 브로드 케스팅된 체팅 데이터를 읽는다.") @Test public void getGreeting() throws Exception { @@ -57,12 +58,12 @@ public void afterConnected(final StompSession session, StompHeaders connectedHea session.subscribe("/topic/chat/new", new StompFrameHandler() { @Override public Type getPayloadType(StompHeaders headers) { - return ChatRequest.class; + return CreateChatRequest.class; } @Override public void handleFrame(StompHeaders headers, Object payload) { - var chatRequest = (ChatRequest) payload; + var chatRequest = (CreateChatRequest) payload; try { assertThat("hello, spring!").isEqualTo(chatRequest.getMessage()); assertThat("john").isEqualTo(chatRequest.getSender()); @@ -76,7 +77,7 @@ public void handleFrame(StompHeaders headers, Object payload) { } }); try { - var request = new ChatRequest(); + var request = new CreateChatRequest(); request.setMessage("hello, spring!"); request.setSender("john"); request.setRoomId(100L); @@ -91,12 +92,8 @@ public void handleFrame(StompHeaders headers, Object payload) { this.stompClient.connectAsync("ws://localhost:{port}/chatting", this.headers, handler, this.port); if (latch.await(3, TimeUnit.SECONDS)) { - if (failure.get() != null) { - throw new AssertionError("", failure.get()); - } - } else { - fail("not received"); - } + if (failure.get() != null) throw new AssertionError("", failure.get()); + } else fail("not received"); } diff --git a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java index 9958406..d37e202 100644 --- a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java @@ -28,5 +28,21 @@ public void saveChat() { .extracting("roomId", "senderNickname", "message") .containsExactly(100L, "hello", "hi"); assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCreateAt()).isNotNull(); + } + + @DisplayName("주어진 id의 체팅을 삭제한다.") + @Test + public void deleteOneById() { + //given + Chat chat = chatRepository.save(Chat.builder() + .roomId(100L) + .senderNickname("hello") + .message("hi") + .build()); + //when + chatRepository.deleteById(100L); + //then + assertThat(chatRepository.findById(100L)).isEmpty(); } } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/ChatInfoTest.java b/src/test/java/com/websocket/demo/response/ChatInfoTest.java new file mode 100644 index 0000000..5db8167 --- /dev/null +++ b/src/test/java/com/websocket/demo/response/ChatInfoTest.java @@ -0,0 +1,29 @@ +package com.websocket.demo.response; + +import com.websocket.demo.domain.Chat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +class ChatInfoTest { + + @DisplayName("체팅의 정보를 동일하게 담고 있다.") + @Test + public void from() { + //given + Chat chat = Mockito.spy(Chat.builder() + .message("message") + .senderNickname("sender") + .roomId(100L) + .build()); + given(chat.getId()).willReturn(1L); + //when + ChatInfo info = ChatInfo.from(chat); + //then + assertThat(info).extracting("id", "sender", "message", "roomId") + .containsExactly(1L, "sender", "message", 100L); + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index df4baa6..8276cb1 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -1,58 +1,72 @@ package com.websocket.demo.service; -import com.websocket.demo.domain.Chat; +import com.websocket.demo.SpringTest; import com.websocket.demo.repository.ChatRepository; -import com.websocket.demo.request.ChatRequest; +import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.DeleteChatRequest; import com.websocket.demo.response.ChatInfo; -import org.junit.jupiter.api.BeforeEach; +import com.websocket.demo.response.DeleteChat; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -class ChatServiceTest { +class ChatServiceTest extends SpringTest { + @Autowired ChatService chatService; + @Autowired ChatRepository chatRepository; - @BeforeEach - void init() { - chatRepository = mock(ChatRepository.class); - chatService = new ChatService(chatRepository); - } - @DisplayName("체팅을 전달 받으면 저장하고 저장된 체팅 정보를 반환한다.") @Test public void createChatTest() { //given - var request = new ChatRequest(); + var request = new CreateChatRequest(); request.setSender("hello"); request.setMessage("welcome to the chat service"); request.setRoomId(100L); - - given(chatRepository.save(any())) - .willAnswer(invocation -> invocation.getArgument(0, Chat.class)); //when - ChatInfo info = chatService.createChat(request); + ChatInfo info = chatService.create(request); //then - assertThat(info).extracting("sender", "message", "roomId") + assertThat(info) + .extracting("sender", "message", "roomId") .containsExactly("hello", "welcome to the chat service", 100L); + assertThat(info.getId()).isNotNull(); } @DisplayName("체팅을 전달 받고 저장이 실패하면 오류가 발생한다.") @Test public void createChatFail() { //given - var request = new ChatRequest(); + var request = new CreateChatRequest(); request.setSender("hello"); request.setMessage("welcome to the chat service"); - - given(chatRepository.save(any())).willThrow(RuntimeException.class); //when //then - assertThatThrownBy(() -> chatService.createChat(request)); + assertThatThrownBy(() -> chatService.create(request)); + } + @DisplayName("체팅 삭제 성공할 때 예외발생하지 않는다.") + @Test + public void deleteChatSuccess() { + //given + var request = new DeleteChatRequest(); + request.setId(100L); + request.setRoomId(10L); + //when + DeleteChat response = chatService.delete(request); + // then + assertThat(response).extracting("id", "roomId") + .containsExactly(100L, 10L); + } + @DisplayName("체팅 삭제가 실패한다면 예외가 발생한다") + @Test + public void deleteChatFail() { + //given + var request = new DeleteChatRequest(); + //when //then + assertThatThrownBy(() -> chatService.delete(request)) + .isInstanceOf(RuntimeException.class); } } \ No newline at end of file From 1afc23d5c5d8dbded7ff34f51a7495daccb4d976 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Fri, 15 Sep 2023 19:52:36 +0900 Subject: [PATCH 05/14] =?UTF-8?q?=EC=B2=B4=ED=8C=85=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/websocket/demo/api/ChatApi.java | 23 +++++ .../demo/controller/ChatController.java | 5 -- .../java/com/websocket/demo/domain/Chat.java | 2 +- .../java/com/websocket/demo/domain/Room.java | 11 +++ .../demo/repository/ChatRepository.java | 4 + .../demo/request/FindChatListRequest.java | 15 ++++ .../websocket/demo/response/ApiResponse.java | 16 ++++ .../com/websocket/demo/response/ChatInfo.java | 4 + .../websocket/demo/service/ChatService.java | 11 +++ .../com/websocket/demo/api/ChatApiTest.java | 87 +++++++++++++++++++ .../demo/repository/ChatRepositoryTest.java | 57 +++++++++++- .../demo/repository/RoomRepository.java | 7 ++ .../demo/response/ApiResponseTest.java | 20 +++++ .../websocket/demo/response/ChatInfoTest.java | 8 +- .../demo/service/ChatServiceTest.java | 46 +++++++++- 15 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/websocket/demo/api/ChatApi.java create mode 100644 src/main/java/com/websocket/demo/request/FindChatListRequest.java create mode 100644 src/main/java/com/websocket/demo/response/ApiResponse.java create mode 100644 src/test/java/com/websocket/demo/api/ChatApiTest.java create mode 100644 src/test/java/com/websocket/demo/repository/RoomRepository.java create mode 100644 src/test/java/com/websocket/demo/response/ApiResponseTest.java diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ChatApi.java new file mode 100644 index 0000000..eedaa83 --- /dev/null +++ b/src/main/java/com/websocket/demo/api/ChatApi.java @@ -0,0 +1,23 @@ +package com.websocket.demo.api; + +import com.websocket.demo.request.FindChatListRequest; +import com.websocket.demo.response.ApiResponse; +import com.websocket.demo.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping +@RequiredArgsConstructor +public class ChatApi { + + private final ChatService chatService; + + @GetMapping("/chat") + public ApiResponse getChattingList(@ModelAttribute FindChatListRequest request){ + return ApiResponse.success(chatService.findChatList(request)); + } +} diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java index ed9e72b..fde42c3 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -6,16 +6,11 @@ import com.websocket.demo.response.DeleteChat; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; @Controller -@RequestMapping @RequiredArgsConstructor public class ChatController { diff --git a/src/main/java/com/websocket/demo/domain/Chat.java b/src/main/java/com/websocket/demo/domain/Chat.java index 9b672ee..9030c01 100644 --- a/src/main/java/com/websocket/demo/domain/Chat.java +++ b/src/main/java/com/websocket/demo/domain/Chat.java @@ -27,7 +27,7 @@ public class Chat { private String message; @CreatedDate @Temporal(TemporalType.TIMESTAMP) - private LocalDateTime createAt; + private LocalDateTime createdAt; @Builder private Chat(Long roomId, String senderNickname, String message) { diff --git a/src/main/java/com/websocket/demo/domain/Room.java b/src/main/java/com/websocket/demo/domain/Room.java index c433fd1..aa16551 100644 --- a/src/main/java/com/websocket/demo/domain/Room.java +++ b/src/main/java/com/websocket/demo/domain/Room.java @@ -1,8 +1,14 @@ package com.websocket.demo.domain; import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Room { @Id @@ -10,4 +16,9 @@ public class Room { private Long id; @Column private String title; + + @Builder + private Room(String title) { + this.title = title; + } } diff --git a/src/main/java/com/websocket/demo/repository/ChatRepository.java b/src/main/java/com/websocket/demo/repository/ChatRepository.java index 7e9fd9d..1e70933 100644 --- a/src/main/java/com/websocket/demo/repository/ChatRepository.java +++ b/src/main/java/com/websocket/demo/repository/ChatRepository.java @@ -3,5 +3,9 @@ import com.websocket.demo.domain.Chat; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; + public interface ChatRepository extends JpaRepository { + List findByRoomIdAndCreatedAtBetween(Long roomId, LocalDateTime to, LocalDateTime from); } diff --git a/src/main/java/com/websocket/demo/request/FindChatListRequest.java b/src/main/java/com/websocket/demo/request/FindChatListRequest.java new file mode 100644 index 0000000..edcce2a --- /dev/null +++ b/src/main/java/com/websocket/demo/request/FindChatListRequest.java @@ -0,0 +1,15 @@ +package com.websocket.demo.request; + +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +@Data +public class FindChatListRequest { + private Long roomId; + @DateTimeFormat + private LocalDateTime from; + @DateTimeFormat + private LocalDateTime to; +} diff --git a/src/main/java/com/websocket/demo/response/ApiResponse.java b/src/main/java/com/websocket/demo/response/ApiResponse.java new file mode 100644 index 0000000..54317a9 --- /dev/null +++ b/src/main/java/com/websocket/demo/response/ApiResponse.java @@ -0,0 +1,16 @@ +package com.websocket.demo.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + private final String status; + private final Object data; + public static ApiResponse success(Object result) { + return new ApiResponse("success", result); + } +} diff --git a/src/main/java/com/websocket/demo/response/ChatInfo.java b/src/main/java/com/websocket/demo/response/ChatInfo.java index 0943efc..c888afd 100644 --- a/src/main/java/com/websocket/demo/response/ChatInfo.java +++ b/src/main/java/com/websocket/demo/response/ChatInfo.java @@ -3,11 +3,14 @@ import com.websocket.demo.domain.Chat; import lombok.Data; +import java.time.LocalDateTime; + @Data public class ChatInfo { private Long id; private String sender; private String message; + private LocalDateTime createdAt; private Long roomId; public static ChatInfo from(Chat chat){ @@ -16,6 +19,7 @@ public static ChatInfo from(Chat chat){ chatInfo.setMessage(chat.getMessage()); chatInfo.setSender(chat.getSenderNickname()); chatInfo.setRoomId(chat.getRoomId()); + chatInfo.setCreatedAt(chat.getCreatedAt()); return chatInfo; } } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index 8f4a3b4..983e201 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -4,12 +4,15 @@ import com.websocket.demo.repository.ChatRepository; import com.websocket.demo.request.CreateChatRequest; import com.websocket.demo.request.DeleteChatRequest; +import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional @@ -31,4 +34,12 @@ public DeleteChat delete(DeleteChatRequest request) { chatRepository.deleteById(request.getId()); return new DeleteChat(request.getRoomId(), request.getId()); } + + public List findChatList(FindChatListRequest req) { + return chatRepository.findByRoomIdAndCreatedAtBetween( + req.getRoomId(), req.getFrom(), req.getTo() + ).stream() + .map(ChatInfo::from) + .toList(); + } } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java new file mode 100644 index 0000000..ed4c451 --- /dev/null +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -0,0 +1,87 @@ +package com.websocket.demo.api; + +import com.websocket.demo.controller.RestDocs; +import com.websocket.demo.domain.Chat; +import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.service.ChatService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ChatApiTest extends RestDocs { + + @MockBean + ChatService chatService; + + @DisplayName("체팅방의 체팅 목록을 조회한다.") + @Test + public void getChattingList() throws Exception { + //given + var result = ChatInfo.from(createChat(1L, "nickname", "contents..", 100L, + LocalDateTime.of(1999, 10, 10, 12, 10, 10))); + given(chatService.findChatList(any())).willReturn(List.of(result)); + //when then + mockMvc.perform(get("/chat") + .queryParam("roomId", "100") + .queryParam("from", "1999-10-10T00:00:00") + .queryParam("to", "1999-10-11T00:00:00") + .accept(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpectAll( + jsonPath("$.status").value("success"), + jsonPath("$.data").exists(), + jsonPath("$.data[0].id").value(result.getId()), + jsonPath("$.data[0].sender").value(result.getSender()), + jsonPath("$.data[0].message").value(result.getMessage()), + jsonPath("$.data[0].roomId").value(result.getRoomId()), + jsonPath("$.data[0].createdAt").exists() + ) + .andDo( + document("get-chatList", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("roomId").description("채팅방 id"), + parameterWithName("from").description("검색 조건 : 체팅 생성 구간 from (format=yyyy-MM-dd)"), + parameterWithName("to").description("검색 조건 : 체팅 생성 구간 to (format=yyyy-MM-dd)") + ), + responseFields( + fieldWithPath("status").description("요청 처리 결과"), + fieldWithPath("data[].id").description("채팅 id"), + fieldWithPath("data[].sender").description("발신자"), + fieldWithPath("data[].message").description("채팅 내용"), + fieldWithPath("data[].roomId").description("채킹방 id"), + fieldWithPath("data[].createdAt").description("생성시간") + ) + ) + ); + } + + private Chat createChat(long id, String nickname, String message, long roomId, LocalDateTime createdAt) { + Chat chat = spy(Chat.builder() + .roomId(roomId) + .message(message) + .senderNickname(nickname) + .build()); + given(chat.getId()).willReturn(id); + given(chat.getCreatedAt()).willReturn(createdAt); + return chat; + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java index d37e202..51a8548 100644 --- a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java @@ -2,16 +2,24 @@ import com.websocket.demo.SpringTest; import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDateTime; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; class ChatRepositoryTest extends SpringTest { @Autowired ChatRepository chatRepository; + @Autowired + RoomRepository roomRepository; + @DisplayName("체팅이 저장된다.") @Test public void saveChat() { @@ -28,7 +36,7 @@ public void saveChat() { .extracting("roomId", "senderNickname", "message") .containsExactly(100L, "hello", "hi"); assertThat(saved.getId()).isNotNull(); - assertThat(saved.getCreateAt()).isNotNull(); + assertThat(saved.getCreatedAt()).isNotNull(); } @DisplayName("주어진 id의 체팅을 삭제한다.") @@ -45,4 +53,51 @@ public void deleteOneById() { //then assertThat(chatRepository.findById(100L)).isEmpty(); } + + @DisplayName("특정 기간의 체팅을 조회한다.") + @Test + public void findByRoomIdAndCreatedAtBetween() { + //given + Room room = roomRepository.saveAndFlush(Room.builder() + .title("test room") + .build()); + + Chat chat = chatRepository.save(Chat.builder() + .roomId(room.getId()) + .senderNickname("hello") + .message("hi") + .build()); + var from = LocalDateTime.of(1999, 10, 11, 0, 0, 0); + var to = LocalDateTime.of(2030, 10, 11, 0, 0, 0); + //when + List result = chatRepository.findByRoomIdAndCreatedAtBetween(room.getId(), from, to); + //then + assertThat(result).extracting("senderNickname", "message", "roomId") + .containsExactly( + tuple("hello", "hi", room.getId()) + ); + } + + @DisplayName("무의미한 기간의 체팅을 조회시 빈 리스트를 반환한다.") + @Test + public void findByRoomIdAndCreatedAtBetweenFail() { + //given + Room room = roomRepository.saveAndFlush(Room.builder() + .title("test room") + .build()); + + Chat chat = chatRepository.save(Chat.builder() + .roomId(room.getId()) + .senderNickname("hello") + .message("hi") + .build()); + var from = LocalDateTime.of(1999, 10, 11, 0, 0, 0); + var to = LocalDateTime.of(1999, 10, 12, 0, 0, 0); + //when + List result = chatRepository.findByRoomIdAndCreatedAtBetween(room.getId(), from, to); + //then + assertThat(result).extracting("senderNickname", "message", "roomId") + .isEmpty(); + } + } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/repository/RoomRepository.java b/src/test/java/com/websocket/demo/repository/RoomRepository.java new file mode 100644 index 0000000..a722a7a --- /dev/null +++ b/src/test/java/com/websocket/demo/repository/RoomRepository.java @@ -0,0 +1,7 @@ +package com.websocket.demo.repository; + +import com.websocket.demo.domain.Room; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoomRepository extends JpaRepository { +} diff --git a/src/test/java/com/websocket/demo/response/ApiResponseTest.java b/src/test/java/com/websocket/demo/response/ApiResponseTest.java new file mode 100644 index 0000000..6b1ce46 --- /dev/null +++ b/src/test/java/com/websocket/demo/response/ApiResponseTest.java @@ -0,0 +1,20 @@ +package com.websocket.demo.response; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseTest { + + @DisplayName("성공시 상태 메시지는 success 이다.") + @Test + public void success() { + //given //when + var response = ApiResponse.success("test data"); + //then + assertThat(response).extracting("status", "data") + .containsExactly("success", "test data"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/ChatInfoTest.java b/src/test/java/com/websocket/demo/response/ChatInfoTest.java index 5db8167..a947a43 100644 --- a/src/test/java/com/websocket/demo/response/ChatInfoTest.java +++ b/src/test/java/com/websocket/demo/response/ChatInfoTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import java.time.LocalDateTime; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -19,11 +21,13 @@ public void from() { .senderNickname("sender") .roomId(100L) .build()); + LocalDateTime time = LocalDateTime.now(); given(chat.getId()).willReturn(1L); + given(chat.getCreatedAt()).willReturn(time); //when ChatInfo info = ChatInfo.from(chat); //then - assertThat(info).extracting("id", "sender", "message", "roomId") - .containsExactly(1L, "sender", "message", 100L); + assertThat(info).extracting("id", "sender", "message", "roomId", "createdAt") + .containsExactly(1L, "sender", "message", 100L, time); } } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index 8276cb1..9c86c3c 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -1,17 +1,23 @@ package com.websocket.demo.service; import com.websocket.demo.SpringTest; +import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.repository.RoomRepository; import com.websocket.demo.request.CreateChatRequest; import com.websocket.demo.request.DeleteChatRequest; +import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; class ChatServiceTest extends SpringTest { @@ -19,6 +25,8 @@ class ChatServiceTest extends SpringTest { ChatService chatService; @Autowired ChatRepository chatRepository; + @Autowired + RoomRepository roomRepository; @DisplayName("체팅을 전달 받으면 저장하고 저장된 체팅 정보를 반환한다.") @Test @@ -47,6 +55,7 @@ public void createChatFail() { //when //then assertThatThrownBy(() -> chatService.create(request)); } + @DisplayName("체팅 삭제 성공할 때 예외발생하지 않는다.") @Test public void deleteChatSuccess() { @@ -60,6 +69,7 @@ public void deleteChatSuccess() { assertThat(response).extracting("id", "roomId") .containsExactly(100L, 10L); } + @DisplayName("체팅 삭제가 실패한다면 예외가 발생한다") @Test public void deleteChatFail() { @@ -69,4 +79,36 @@ public void deleteChatFail() { assertThatThrownBy(() -> chatService.delete(request)) .isInstanceOf(RuntimeException.class); } + + @DisplayName("조건에 맞는 채팅 리스트를 찾는다.") + @Test + public void findChatList() { + //given + Room room = roomRepository.saveAndFlush(Room.builder() + .title("chatting room 1") + .build()); + saveChat(room.getId(), "nick", "hello"); + saveChat(room.getId(), "tom", "bye"); + + var request = new FindChatListRequest(); + request.setRoomId(room.getId()); + request.setFrom(LocalDateTime.of(1999, 1, 1, 0, 0, 0)); + request.setTo(LocalDateTime.of(2030, 1, 1, 0, 0, 0)); + //when + List result = chatService.findChatList(request); + //then + assertThat(result).extracting("sender", "message", "roomId") + .containsExactly( + tuple("nick", "hello", room.getId()), + tuple("tom", "bye", room.getId()) + ); + } + + private Chat saveChat(Long roomId, String sender, String message) { + return chatRepository.saveAndFlush(Chat.builder() + .message(message) + .senderNickname(sender) + .roomId(roomId) + .build()); + } } \ No newline at end of file From 757a66f9ca68416ee1d019e52064da43a14d8cb3 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Sat, 16 Sep 2023 10:34:49 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/websocket/demo/api/ChatApi.java | 22 +++- .../demo/controller/ChatController.java | 2 +- .../java/com/websocket/demo/domain/Chat.java | 9 +- .../java/com/websocket/demo/domain/Room.java | 17 +++ .../websocket/demo/domain/RoomUserData.java | 35 +++++ .../demo/repository/RoomRepository.java | 3 + .../demo/request/CreateRoomRequest.java | 15 +++ .../com/websocket/demo/response/ChatInfo.java | 2 +- .../com/websocket/demo/response/RoomInfo.java | 31 +++++ .../websocket/demo/service/ChatService.java | 25 +++- .../com/websocket/demo/api/ChatApiTest.java | 121 ++++++++++++++++-- .../com/websocket/demo/domain/RoomTest.java | 30 +++++ .../websocket/demo/integrate/ChatTest.java | 17 ++- .../demo/repository/ChatRepositoryTest.java | 22 +++- .../demo/repository/RoomRepositoryTest.java | 72 +++++++++++ .../websocket/demo/response/ChatInfoTest.java | 8 +- .../websocket/demo/response/RoomInfoTest.java | 70 ++++++++++ .../demo/service/ChatServiceTest.java | 83 ++++++++++-- 18 files changed, 536 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/websocket/demo/domain/RoomUserData.java rename src/{test => main}/java/com/websocket/demo/repository/RoomRepository.java (72%) create mode 100644 src/main/java/com/websocket/demo/request/CreateRoomRequest.java create mode 100644 src/main/java/com/websocket/demo/response/RoomInfo.java create mode 100644 src/test/java/com/websocket/demo/domain/RoomTest.java create mode 100644 src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java create mode 100644 src/test/java/com/websocket/demo/response/RoomInfoTest.java diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ChatApi.java index eedaa83..c8c666c 100644 --- a/src/main/java/com/websocket/demo/api/ChatApi.java +++ b/src/main/java/com/websocket/demo/api/ChatApi.java @@ -1,13 +1,14 @@ package com.websocket.demo.api; +import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.FindChatListRequest; +import com.websocket.demo.request.LoginRequest; import com.websocket.demo.response.ApiResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import static com.websocket.demo.response.ApiResponse.*; @RestController @RequestMapping @@ -17,7 +18,16 @@ public class ChatApi { private final ChatService chatService; @GetMapping("/chat") - public ApiResponse getChattingList(@ModelAttribute FindChatListRequest request){ - return ApiResponse.success(chatService.findChatList(request)); + public ApiResponse getChattingList(@ModelAttribute FindChatListRequest request) { + return success(chatService.findChatList(request)); + } + + @GetMapping("/room") + public ApiResponse getRoomList(@SessionAttribute("user") LoginRequest userInfo ) { + return success(chatService.findRoomList(userInfo.getNickname())); + } + @PostMapping("/room") + public ApiResponse createRoomList(@RequestBody CreateRoomRequest request){ + return success(chatService.createRoom(request)); } } diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatController.java index fde42c3..946212e 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatController.java @@ -19,7 +19,7 @@ public class ChatController { @MessageMapping("/chat/new") @SendTo("/topic/chat/new") public ChatInfo newChat(CreateChatRequest request) { - return chatService.create(request); + return chatService.createChat(request); } @MessageMapping("/chat/delete") diff --git a/src/main/java/com/websocket/demo/domain/Chat.java b/src/main/java/com/websocket/demo/domain/Chat.java index 9030c01..02a717d 100644 --- a/src/main/java/com/websocket/demo/domain/Chat.java +++ b/src/main/java/com/websocket/demo/domain/Chat.java @@ -19,8 +19,9 @@ public class Chat { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; - @Column(nullable = false, updatable = false) - private Long roomId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false, updatable = false) + private Room room; @Column(nullable = false, updatable = false) private String senderNickname; @Column @@ -30,8 +31,8 @@ public class Chat { private LocalDateTime createdAt; @Builder - private Chat(Long roomId, String senderNickname, String message) { - this.roomId = roomId; + private Chat(Room room, String senderNickname, String message) { + this.room = room; this.senderNickname = senderNickname; this.message = message; } diff --git a/src/main/java/com/websocket/demo/domain/Room.java b/src/main/java/com/websocket/demo/domain/Room.java index aa16551..b53c8f8 100644 --- a/src/main/java/com/websocket/demo/domain/Room.java +++ b/src/main/java/com/websocket/demo/domain/Room.java @@ -5,6 +5,10 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -16,9 +20,22 @@ public class Room { private Long id; @Column private String title; + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List data = new ArrayList<>(); + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY) + @BatchSize(size = 100) + private List chatList = new ArrayList<>(); @Builder private Room(String title) { this.title = title; } + + public void addUser(String userNickname) { + data.add(RoomUserData.builder() + .room(this) + .backgroundColor("white") + .userNickname(userNickname) + .build()); + } } diff --git a/src/main/java/com/websocket/demo/domain/RoomUserData.java b/src/main/java/com/websocket/demo/domain/RoomUserData.java new file mode 100644 index 0000000..9038d04 --- /dev/null +++ b/src/main/java/com/websocket/demo/domain/RoomUserData.java @@ -0,0 +1,35 @@ +package com.websocket.demo.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RoomUserData { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + private Room room; + @Column + private String userNickname; + @Column + private LocalDateTime checkTime; + @Column + private String backgroundColor; + + @Builder + private RoomUserData(Room room, String userNickname, String backgroundColor) { + this.room = room; + this.userNickname = userNickname; + this.checkTime = LocalDateTime.now(); + this.backgroundColor = backgroundColor; + } +} diff --git a/src/test/java/com/websocket/demo/repository/RoomRepository.java b/src/main/java/com/websocket/demo/repository/RoomRepository.java similarity index 72% rename from src/test/java/com/websocket/demo/repository/RoomRepository.java rename to src/main/java/com/websocket/demo/repository/RoomRepository.java index a722a7a..788c5d9 100644 --- a/src/test/java/com/websocket/demo/repository/RoomRepository.java +++ b/src/main/java/com/websocket/demo/repository/RoomRepository.java @@ -3,5 +3,8 @@ import com.websocket.demo.domain.Room; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface RoomRepository extends JpaRepository { + List findByDataUserNickname(String nickname); } diff --git a/src/main/java/com/websocket/demo/request/CreateRoomRequest.java b/src/main/java/com/websocket/demo/request/CreateRoomRequest.java new file mode 100644 index 0000000..60588fd --- /dev/null +++ b/src/main/java/com/websocket/demo/request/CreateRoomRequest.java @@ -0,0 +1,15 @@ +package com.websocket.demo.request; + +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class CreateRoomRequest { + private String title; + @Size(min = 1, message = "채팅방 생성시 유저는 반드시 한 명 이상이어야 합니다.") + private List users; +} diff --git a/src/main/java/com/websocket/demo/response/ChatInfo.java b/src/main/java/com/websocket/demo/response/ChatInfo.java index c888afd..6278cc6 100644 --- a/src/main/java/com/websocket/demo/response/ChatInfo.java +++ b/src/main/java/com/websocket/demo/response/ChatInfo.java @@ -18,7 +18,7 @@ public static ChatInfo from(Chat chat){ chatInfo.setId(chat.getId()); chatInfo.setMessage(chat.getMessage()); chatInfo.setSender(chat.getSenderNickname()); - chatInfo.setRoomId(chat.getRoomId()); + chatInfo.setRoomId(chat.getRoom().getId()); chatInfo.setCreatedAt(chat.getCreatedAt()); return chatInfo; } diff --git a/src/main/java/com/websocket/demo/response/RoomInfo.java b/src/main/java/com/websocket/demo/response/RoomInfo.java new file mode 100644 index 0000000..8b6fc51 --- /dev/null +++ b/src/main/java/com/websocket/demo/response/RoomInfo.java @@ -0,0 +1,31 @@ +package com.websocket.demo.response; + +import com.websocket.demo.domain.Room; +import com.websocket.demo.domain.RoomUserData; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class RoomInfo { + + private final Long id; + private final String title; + private final List users; + private final List chat; + + public static RoomInfo from(Room room) { + return new RoomInfo( + room.getId(), + room.getTitle(), + room.getData().stream() + .map(RoomUserData::getUserNickname) + .toList(), + room.getChatList().stream() + .map(ChatInfo::from) + .toList() + ); + } +} diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index 983e201..73185ae 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -1,12 +1,16 @@ package com.websocket.demo.service; import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.repository.RoomRepository; import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.DeleteChatRequest; import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; +import com.websocket.demo.response.RoomInfo; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -19,11 +23,14 @@ public class ChatService { private final ChatRepository chatRepository; + private final RoomRepository roomRepository; - public ChatInfo create(CreateChatRequest request) { + public ChatInfo createChat(CreateChatRequest request) { + var room = roomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new RuntimeException("존재하지 않는 채팅방 입니다.")); var chat = chatRepository.saveAndFlush(Chat.builder() .senderNickname(request.getSender()) - .roomId(request.getRoomId()) + .room(room) .message(request.getMessage()) .build() ); @@ -42,4 +49,18 @@ public List findChatList(FindChatListRequest req) { .map(ChatInfo::from) .toList(); } + + public List findRoomList(String nickname) { + List rooms = roomRepository.findByDataUserNickname(nickname); + return rooms.stream().map(RoomInfo::from).toList(); + } + + public RoomInfo createRoom(CreateRoomRequest request) { + Room room = Room.builder() + .title(request.getTitle()) + .build(); + request.getUsers().forEach(room::addUser); + + return RoomInfo.from(roomRepository.save(room)); + } } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java index ed4c451..fb62731 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -2,12 +2,18 @@ import com.websocket.demo.controller.RestDocs; import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; +import com.websocket.demo.request.CreateRoomRequest; +import com.websocket.demo.request.LoginRequest; import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.response.RoomInfo; import com.websocket.demo.service.ChatService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.time.LocalDateTime; import java.util.List; @@ -17,8 +23,7 @@ import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -29,24 +34,25 @@ class ChatApiTest extends RestDocs { @MockBean ChatService chatService; - @DisplayName("체팅방의 체팅 목록을 조회한다.") + @DisplayName("체팅방의 체팅 목록 조회 API") @Test public void getChattingList() throws Exception { //given - var result = ChatInfo.from(createChat(1L, "nickname", "contents..", 100L, + var room = spy(Room.class); + var result = ChatInfo.from(createChat(1L, "nickname", "contents..", room, LocalDateTime.of(1999, 10, 10, 12, 10, 10))); given(chatService.findChatList(any())).willReturn(List.of(result)); + given(room.getId()).willReturn(100L); //when then mockMvc.perform(get("/chat") - .queryParam("roomId", "100") - .queryParam("from", "1999-10-10T00:00:00") - .queryParam("to", "1999-10-11T00:00:00") - .accept(MediaType.APPLICATION_JSON) + .queryParam("roomId", "100") + .queryParam("from", "1999-10-10T00:00:00") + .queryParam("to", "1999-10-11T00:00:00") + .accept(MediaType.APPLICATION_JSON) ) .andDo(print()) .andExpectAll( jsonPath("$.status").value("success"), - jsonPath("$.data").exists(), jsonPath("$.data[0].id").value(result.getId()), jsonPath("$.data[0].sender").value(result.getSender()), jsonPath("$.data[0].message").value(result.getMessage()), @@ -74,9 +80,102 @@ public void getChattingList() throws Exception { ); } - private Chat createChat(long id, String nickname, String message, long roomId, LocalDateTime createdAt) { + @DisplayName("체팅방 생성 API") + @Test + public void createRoom() throws Exception { + //given + var request = new CreateRoomRequest(); + request.setTitle("room title"); + request.setUsers(List.of("user1", "user2", "user3")); + var response = new RoomInfo( + 100L, + "room title", + List.of("user1", "user2", "user3"), + List.of() + ); + given(chatService.createRoom(request)).willReturn(response); + //when //then + String content = mapper.writeValueAsString(request); + mockMvc.perform(MockMvcRequestBuilders.post("/room") + .content(content) + .contentType(MediaType.APPLICATION_JSON) + ).andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.status").value("success"), + jsonPath("$.data.id").exists(), + jsonPath("$.data.title").exists(), + jsonPath("$.data.users").exists(), + jsonPath("$.data.chat").exists() + ).andDo( + document("post-create-room", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("title").description("채팅방 이름"), + fieldWithPath("users").description("채팅방 구성원 닉네임 목록으로 반드시 하나 이상의 닉네임을 포함해야한다.") + ), + responseFields( + fieldWithPath("status").description("요청 처리 결과"), + fieldWithPath("data.id").description("채팅방 id"), + fieldWithPath("data.title").description("채팅방 이름"), + fieldWithPath("data.users").description("채팅방 구성원 닉네임 목록"), + fieldWithPath("data.chat").description("채팅 목록") + ) + ) + ); + } + + @DisplayName("채팅방 조회 API") + @Test + public void getRoomList() throws Exception { + //given + var info = new LoginRequest(); + info.setNickname("nickname"); + var chatInfo = new ChatInfo(); + chatInfo.setId(1L); + chatInfo.setRoomId(100L); + chatInfo.setSender("john"); + chatInfo.setMessage("hello"); + chatInfo.setCreatedAt(LocalDateTime.of(2000, 12, 12, 12, 12, 12)); + + var roomInfo = new RoomInfo(100L, "room title", List.of(), List.of(chatInfo)); + given(chatService.findRoomList("nickname")).willReturn(List.of(roomInfo)); + //when then + mockMvc.perform(get("/room").sessionAttr("user", info)) + .andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.status").value("success"), + jsonPath("$.data[0].id").value(roomInfo.getId()), + jsonPath("$.data[0].title").value(roomInfo.getTitle()), + jsonPath("$.data[0].chat[0].id").value(chatInfo.getId()), + jsonPath("$.data[0].chat[0].sender").value(chatInfo.getSender()), + jsonPath("$.data[0].chat[0].message").value(chatInfo.getMessage()), + jsonPath("$.data[0].chat[0].roomId").value(chatInfo.getRoomId()), + jsonPath("$.data[0].chat[0].createdAt").exists() + ).andDo( + document("get-room-list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("status").description("요청 처리 결과"), + fieldWithPath("data[].id").description("채팅방 id"), + fieldWithPath("data[].title").description("채팅방 제목"), + fieldWithPath("data[].chat[].id").description("채팅 id"), + fieldWithPath("data[].users").description("채팅방 유저 닉네임 목록"), + fieldWithPath("data[].chat[].sender").description("발신자"), + fieldWithPath("data[].chat[].message").description("채팅 내용"), + fieldWithPath("data[].chat[].roomId").description("채킹방 id"), + fieldWithPath("data[].chat[].createdAt").description("채팅 생성시간") + ) + ) + ); + } + + private Chat createChat(long id, String nickname, String message, Room room, LocalDateTime createdAt) { Chat chat = spy(Chat.builder() - .roomId(roomId) + .room(room) .message(message) .senderNickname(nickname) .build()); diff --git a/src/test/java/com/websocket/demo/domain/RoomTest.java b/src/test/java/com/websocket/demo/domain/RoomTest.java new file mode 100644 index 0000000..20678d1 --- /dev/null +++ b/src/test/java/com/websocket/demo/domain/RoomTest.java @@ -0,0 +1,30 @@ +package com.websocket.demo.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class RoomTest { + + @DisplayName("닉네임으로 유저를 추가할 수 있다") + @Test + public void addUser() { + //given + Room room = Room.builder() + .title("room1") + .build(); + //when + room.addUser("nick"); + room.addUser("ann"); + //then + assertThat(room.getData()) + .extracting("userNickname", "backgroundColor", "room") + .contains( + tuple("nick", "white", room), + tuple("ann", "white", room) + ); + } + +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/integrate/ChatTest.java b/src/test/java/com/websocket/demo/integrate/ChatTest.java index d928475..6aaf8a6 100644 --- a/src/test/java/com/websocket/demo/integrate/ChatTest.java +++ b/src/test/java/com/websocket/demo/integrate/ChatTest.java @@ -3,9 +3,13 @@ import com.websocket.demo.SpringTest; import com.websocket.demo.interceptor.ChatHandshakeInterceptor; import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.CreateRoomRequest; +import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.service.ChatService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.messaging.converter.MappingJackson2MessageConverter; @@ -16,6 +20,7 @@ import org.springframework.web.socket.messaging.WebSocketStompClient; import java.lang.reflect.Type; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -31,6 +36,8 @@ public class ChatTest extends SpringTest { private int port; @MockBean ChatHandshakeInterceptor chatHandshakeInterceptor; + @Autowired + ChatService chatService; private WebSocketStompClient stompClient; @@ -49,6 +56,10 @@ public void getGreeting() throws Exception { given(chatHandshakeInterceptor.beforeHandshake(any(), any(), any(), any())) .willReturn(true); + var createRoomRequest = new CreateRoomRequest(); + createRoomRequest.setTitle("welcome"); + createRoomRequest.setUsers(List.of("john")); + RoomInfo room = chatService.createRoom(createRoomRequest); final CountDownLatch latch = new CountDownLatch(1); final AtomicReference failure = new AtomicReference<>(); StompSessionHandler handler = new TestSessionHandler(failure) { @@ -67,7 +78,7 @@ public void handleFrame(StompHeaders headers, Object payload) { try { assertThat("hello, spring!").isEqualTo(chatRequest.getMessage()); assertThat("john").isEqualTo(chatRequest.getSender()); - assertThat(100L).isEqualTo(chatRequest.getRoomId()); + assertThat(room.getId()).isEqualTo(chatRequest.getRoomId()); } catch (Throwable t) { failure.set(t); } finally { @@ -80,7 +91,7 @@ public void handleFrame(StompHeaders headers, Object payload) { var request = new CreateChatRequest(); request.setMessage("hello, spring!"); request.setSender("john"); - request.setRoomId(100L); + request.setRoomId(room.getId()); session.send("/app/chat/new", request); } catch (Throwable t) { failure.set(t); @@ -91,7 +102,7 @@ public void handleFrame(StompHeaders headers, Object payload) { this.stompClient.connectAsync("ws://localhost:{port}/chatting", this.headers, handler, this.port); - if (latch.await(3, TimeUnit.SECONDS)) { + if (latch.await(1, TimeUnit.SECONDS)) { if (failure.get() != null) throw new AssertionError("", failure.get()); } else fail("not received"); diff --git a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java index 51a8548..8c795bd 100644 --- a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java @@ -24,8 +24,9 @@ class ChatRepositoryTest extends SpringTest { @Test public void saveChat() { //given + Room room = saveRoom("room1"); Chat chat = Chat.builder() - .roomId(100L) + .room(room) .senderNickname("hello") .message("hi") .build(); @@ -33,8 +34,8 @@ public void saveChat() { Chat saved = chatRepository.save(chat); //then assertThat(saved).isNotNull() - .extracting("roomId", "senderNickname", "message") - .containsExactly(100L, "hello", "hi"); + .extracting("room.id", "senderNickname", "message") + .containsExactly(room.getId(), "hello", "hi"); assertThat(saved.getId()).isNotNull(); assertThat(saved.getCreatedAt()).isNotNull(); } @@ -43,8 +44,9 @@ public void saveChat() { @Test public void deleteOneById() { //given + Room room = saveRoom("room1"); Chat chat = chatRepository.save(Chat.builder() - .roomId(100L) + .room(room) .senderNickname("hello") .message("hi") .build()); @@ -63,7 +65,7 @@ public void findByRoomIdAndCreatedAtBetween() { .build()); Chat chat = chatRepository.save(Chat.builder() - .roomId(room.getId()) + .room(room) .senderNickname("hello") .message("hi") .build()); @@ -72,7 +74,7 @@ public void findByRoomIdAndCreatedAtBetween() { //when List result = chatRepository.findByRoomIdAndCreatedAtBetween(room.getId(), from, to); //then - assertThat(result).extracting("senderNickname", "message", "roomId") + assertThat(result).extracting("senderNickname", "message", "room.id") .containsExactly( tuple("hello", "hi", room.getId()) ); @@ -87,7 +89,7 @@ public void findByRoomIdAndCreatedAtBetweenFail() { .build()); Chat chat = chatRepository.save(Chat.builder() - .roomId(room.getId()) + .room(room) .senderNickname("hello") .message("hi") .build()); @@ -100,4 +102,10 @@ public void findByRoomIdAndCreatedAtBetweenFail() { .isEmpty(); } + private Room saveRoom(String title) { + return roomRepository.saveAndFlush(Room.builder() + .title(title) + .build() + ); + } } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java b/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java new file mode 100644 index 0000000..6da9011 --- /dev/null +++ b/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java @@ -0,0 +1,72 @@ +package com.websocket.demo.repository; + +import com.websocket.demo.SpringTest; +import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; +import jakarta.transaction.Transactional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class RoomRepositoryTest extends SpringTest { + @Autowired + RoomRepository roomRepository; + @Autowired + ChatRepository chatRepository; + + @DisplayName("특정 유저의 체팅방 목록을 조회한다.") + @Test + public void findByDataUserNickname() { + //given + saveRoom("room1", "nick"); + saveRoom("room2", "nick"); + saveRoom("room3", "john"); + //when + List all = roomRepository.findAll(); + List rooms = roomRepository.findByDataUserNickname("nick"); + //then + assertThat(rooms).hasSize(2) + .extracting("title") + .contains("room1", "room2"); + } + + @Transactional + @DisplayName("채팅방에 유저를 추가하면 유저가 채팅방에 가입된다.") + @Test + public void addUser() { + //given + var id = saveRoom("room1", "nick", "john", "kim").getId(); + //when + var room = roomRepository.findById(id).get(); + //then + assertThat(room.getData()) + .extracting("userNickname") + .contains("nick", "john", "kim"); + } + + private Chat saveChat(Room room, String sender, String message) { + return chatRepository.saveAndFlush(Chat.builder() + .message(message) + .senderNickname(sender) + .room(room) + .build()); + } + + private Room saveRoom(String title, String... userNicknames) { + Room room = Room.builder() + .title(title) + .build(); + + for (String nickname : userNicknames) { + room.addUser(nickname); + } + + return roomRepository.saveAndFlush(room); + } + +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/ChatInfoTest.java b/src/test/java/com/websocket/demo/response/ChatInfoTest.java index a947a43..20a8cad 100644 --- a/src/test/java/com/websocket/demo/response/ChatInfoTest.java +++ b/src/test/java/com/websocket/demo/response/ChatInfoTest.java @@ -1,6 +1,7 @@ package com.websocket.demo.response; import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -9,6 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; class ChatInfoTest { @@ -16,14 +18,16 @@ class ChatInfoTest { @Test public void from() { //given - Chat chat = Mockito.spy(Chat.builder() + Room room = spy(Room.builder().build()); + Chat chat = spy(Chat.builder() .message("message") .senderNickname("sender") - .roomId(100L) + .room(room) .build()); LocalDateTime time = LocalDateTime.now(); given(chat.getId()).willReturn(1L); given(chat.getCreatedAt()).willReturn(time); + given(room.getId()).willReturn(100L); //when ChatInfo info = ChatInfo.from(chat); //then diff --git a/src/test/java/com/websocket/demo/response/RoomInfoTest.java b/src/test/java/com/websocket/demo/response/RoomInfoTest.java new file mode 100644 index 0000000..00e62c6 --- /dev/null +++ b/src/test/java/com/websocket/demo/response/RoomInfoTest.java @@ -0,0 +1,70 @@ +package com.websocket.demo.response; + +import com.websocket.demo.domain.Chat; +import com.websocket.demo.domain.Room; +import com.websocket.demo.domain.RoomUserData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.LinkedList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.spy; + +class RoomInfoTest { + + @DisplayName("채팅방의 정보를 담은 객체를 생성시 모든 정보가 일치해야한다.") + @Test + public void convertRoomInfoInfo() { + //given + var room = spy(Room.builder() + .title("rooms") + .build()); + List chatList = new LinkedList<>(); + List roomUserDataList = List.of( + createRoomData(room, "nick", "white", 10L), + createRoomData(room, "john", "white", 11L) + ); + given(room.getId()).willReturn(100L); + given(room.getData()).willReturn(List.of()); + given(room.getChatList()).willReturn(chatList); + given(room.getData()).willReturn(roomUserDataList); + + var chat = spy(Chat.builder() + .room(room) + .message("hello world") + .senderNickname("nick") + .build()); + + given(chat.getId()).willReturn(1L); + given(chat.getCreatedAt()).willReturn(LocalDateTime.of(2000, 10, 10, 0, 0, 0)); + chatList.add(chat); + //when + var roomInfo = RoomInfo.from(room); + //then + assertThat(roomInfo) + .extracting("id", "title") + .containsExactly(100L, "rooms"); + + assertThat(roomInfo.getChat()).extracting("message", "sender", "createdAt", "roomId") + .contains( + tuple("hello world", "nick", chat.getCreatedAt(), 100L) + ); + + assertThat(roomInfo.getUsers()).contains("john", "nick"); + } + + private static RoomUserData createRoomData(Room room, String nickname, String background, long id) { + RoomUserData result = spy(RoomUserData.builder() + .room(room) + .userNickname(nickname) + .backgroundColor(background) + .build()); + given(result.getId()).willReturn(id); + return result; + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index 9c86c3c..bfb8cd9 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -6,10 +6,13 @@ import com.websocket.demo.repository.ChatRepository; import com.websocket.demo.repository.RoomRepository; import com.websocket.demo.request.CreateChatRequest; +import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.DeleteChatRequest; import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; +import com.websocket.demo.response.RoomInfo; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -28,20 +31,27 @@ class ChatServiceTest extends SpringTest { @Autowired RoomRepository roomRepository; + @BeforeEach + public void init(){ + chatRepository.deleteAllInBatch(); + roomRepository.deleteAll(); + } + @DisplayName("체팅을 전달 받으면 저장하고 저장된 체팅 정보를 반환한다.") @Test public void createChatTest() { //given + var room = saveRoom("room1", "john"); var request = new CreateChatRequest(); request.setSender("hello"); request.setMessage("welcome to the chat service"); - request.setRoomId(100L); + request.setRoomId(room.getId()); //when - ChatInfo info = chatService.create(request); + ChatInfo info = chatService.createChat(request); //then assertThat(info) .extracting("sender", "message", "roomId") - .containsExactly("hello", "welcome to the chat service", 100L); + .containsExactly("hello", "welcome to the chat service", room.getId()); assertThat(info.getId()).isNotNull(); } @@ -53,7 +63,7 @@ public void createChatFail() { request.setSender("hello"); request.setMessage("welcome to the chat service"); //when //then - assertThatThrownBy(() -> chatService.create(request)); + assertThatThrownBy(() -> chatService.createChat(request)); } @DisplayName("체팅 삭제 성공할 때 예외발생하지 않는다.") @@ -84,11 +94,9 @@ public void deleteChatFail() { @Test public void findChatList() { //given - Room room = roomRepository.saveAndFlush(Room.builder() - .title("chatting room 1") - .build()); - saveChat(room.getId(), "nick", "hello"); - saveChat(room.getId(), "tom", "bye"); + Room room = saveRoom("chatting room 1", "john"); + saveChat(room, "nick", "hello"); + saveChat(room, "tom", "bye"); var request = new FindChatListRequest(); request.setRoomId(room.getId()); @@ -104,11 +112,64 @@ public void findChatList() { ); } - private Chat saveChat(Long roomId, String sender, String message) { + @DisplayName("특정 유저의 채팅방 목록을 전체 조회한다.") + @Test + public void findRoomList() { + //given + saveRoom("room1", "nick"); + saveRoom("room2", "nick"); + saveRoom("room3", "john"); + //when + List rooms = chatService.findRoomList("nick"); + //then + assertThat(rooms).extracting("title") + .contains("room1", "room2"); + } + + @DisplayName("특정 유저와 일치하는 체팅방이 없다면 빈 배열을 반환한다.") + @Test + public void findRoomListFail() { + //given + saveRoom("only nick room", "nick"); + //when + List rooms = chatService.findRoomList("john"); + //then + assertThat(rooms).isEmpty(); + } + + @DisplayName("채팅방을 생성한다.") + @Test + public void createRoom() { + //given + var request = new CreateRoomRequest(); + request.setTitle("my room"); + request.setUsers(List.of("john", "tom", "nick")); + //when + RoomInfo room = chatService.createRoom(request); + //then + assertThat(room.getTitle()).isEqualTo("my room"); + assertThat(room.getId()).isNotNull(); + assertThat(room.getUsers()).contains("john", "tom", "nick"); + assertThat(room.getChat()).isEmpty(); + } + + private Chat saveChat(Room room, String sender, String message) { return chatRepository.saveAndFlush(Chat.builder() .message(message) .senderNickname(sender) - .roomId(roomId) + .room(room) .build()); } + + private Room saveRoom(String title, String... userNicknames) { + Room room = Room.builder() + .title(title) + .build(); + + for (String nickname : userNicknames) { + room.addUser(nickname); + } + + return roomRepository.saveAndFlush(room); + } } \ No newline at end of file From 52c1d4b3319e69a516537502b6491a2edd9abc4d Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Sun, 17 Sep 2023 09:37:29 +0900 Subject: [PATCH 07/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=82=98?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/websocket/demo/api/ChatApi.java | 6 +++ ...ller.java => ChatWebsocketController.java} | 2 +- .../com/websocket/demo/domain/Friend.java | 9 ---- .../demo/repository/RoomInfoRepository.java | 10 +++++ .../demo/request/RoomOutRequest.java | 8 ++++ .../websocket/demo/response/ApiResponse.java | 3 ++ .../websocket/demo/service/ChatService.java | 13 ++++-- .../com/websocket/demo/api/ChatApiTest.java | 44 +++++++++++++++++-- .../demo/repository/RoomRepositoryTest.java | 3 +- .../demo/service/ChatServiceTest.java | 44 ++++++++++++++++--- 10 files changed, 117 insertions(+), 25 deletions(-) rename src/main/java/com/websocket/demo/controller/{ChatController.java => ChatWebsocketController.java} (96%) create mode 100644 src/main/java/com/websocket/demo/repository/RoomInfoRepository.java create mode 100644 src/main/java/com/websocket/demo/request/RoomOutRequest.java diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ChatApi.java index c8c666c..9655d48 100644 --- a/src/main/java/com/websocket/demo/api/ChatApi.java +++ b/src/main/java/com/websocket/demo/api/ChatApi.java @@ -3,6 +3,7 @@ import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.request.LoginRequest; +import com.websocket.demo.request.RoomOutRequest; import com.websocket.demo.response.ApiResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; @@ -30,4 +31,9 @@ public ApiResponse getRoomList(@SessionAttribute("user") LoginRequest userInfo ) public ApiResponse createRoomList(@RequestBody CreateRoomRequest request){ return success(chatService.createRoom(request)); } + @DeleteMapping("/room") + public ApiResponse getOutRoom(@RequestBody RoomOutRequest request, @SessionAttribute("user") LoginRequest userInfo){ + chatService.getOutRoom(request, userInfo.getNickname()); + return success(null); + } } diff --git a/src/main/java/com/websocket/demo/controller/ChatController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java similarity index 96% rename from src/main/java/com/websocket/demo/controller/ChatController.java rename to src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index 946212e..d548be9 100644 --- a/src/main/java/com/websocket/demo/controller/ChatController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -12,7 +12,7 @@ @Controller @RequiredArgsConstructor -public class ChatController { +public class ChatWebsocketController { private final ChatService chatService; diff --git a/src/main/java/com/websocket/demo/domain/Friend.java b/src/main/java/com/websocket/demo/domain/Friend.java index 1f215c9..244c474 100644 --- a/src/main/java/com/websocket/demo/domain/Friend.java +++ b/src/main/java/com/websocket/demo/domain/Friend.java @@ -35,13 +35,4 @@ public User getFriend() { public FriendInfo toInfo() { return new FriendInfo(getFriend().getNickname()); } - - @Override - public String toString() { - return "Friend{" + - "id=" + id + - ", userNickname='" + userNickname + '\'' + - ", friend=" + friend.getNickname() + - '}'; - } } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java new file mode 100644 index 0000000..e22f62b --- /dev/null +++ b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java @@ -0,0 +1,10 @@ +package com.websocket.demo.repository; + +import com.websocket.demo.domain.RoomUserData; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoomInfoRepository extends JpaRepository { + void deleteByUserNicknameAndRoomId(String nickname, Long roomId); + + boolean existsByUserNicknameAndRoomId(String nickname, Long roomId); +} diff --git a/src/main/java/com/websocket/demo/request/RoomOutRequest.java b/src/main/java/com/websocket/demo/request/RoomOutRequest.java new file mode 100644 index 0000000..4e21166 --- /dev/null +++ b/src/main/java/com/websocket/demo/request/RoomOutRequest.java @@ -0,0 +1,8 @@ +package com.websocket.demo.request; + +import lombok.Data; + +@Data +public class RoomOutRequest { + private Long id; +} diff --git a/src/main/java/com/websocket/demo/response/ApiResponse.java b/src/main/java/com/websocket/demo/response/ApiResponse.java index 54317a9..872fba2 100644 --- a/src/main/java/com/websocket/demo/response/ApiResponse.java +++ b/src/main/java/com/websocket/demo/response/ApiResponse.java @@ -1,5 +1,7 @@ package com.websocket.demo.response; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -9,6 +11,7 @@ public class ApiResponse { private final String status; + @JsonInclude(Include.NON_NULL) private final Object data; public static ApiResponse success(Object result) { return new ApiResponse("success", result); diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index 73185ae..e33b4f3 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -3,11 +3,9 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.repository.RoomInfoRepository; import com.websocket.demo.repository.RoomRepository; -import com.websocket.demo.request.CreateChatRequest; -import com.websocket.demo.request.CreateRoomRequest; -import com.websocket.demo.request.DeleteChatRequest; -import com.websocket.demo.request.FindChatListRequest; +import com.websocket.demo.request.*; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; import com.websocket.demo.response.RoomInfo; @@ -24,6 +22,7 @@ public class ChatService { private final ChatRepository chatRepository; private final RoomRepository roomRepository; + private final RoomInfoRepository roomInfoRepository; public ChatInfo createChat(CreateChatRequest request) { var room = roomRepository.findById(request.getRoomId()) @@ -63,4 +62,10 @@ public RoomInfo createRoom(CreateRoomRequest request) { return RoomInfo.from(roomRepository.save(room)); } + public void getOutRoom(RoomOutRequest request, String nickname) { + roomInfoRepository.deleteByUserNicknameAndRoomId(nickname, request.getId()); + if(!roomInfoRepository.existsByUserNicknameAndRoomId(nickname, request.getId())){ + roomRepository.deleteById(request.getId()); + } + } } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java index fb62731..0ebc087 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -1,25 +1,29 @@ package com.websocket.demo.api; +import com.fasterxml.jackson.core.JsonProcessingException; import com.websocket.demo.controller.RestDocs; import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.LoginRequest; +import com.websocket.demo.request.RoomOutRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.RoomInfo; import com.websocket.demo.service.ChatService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.time.LocalDateTime; import java.util.List; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; +import static org.mockito.Mockito.doNothing; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; @@ -27,7 +31,8 @@ import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class ChatApiTest extends RestDocs { @@ -173,6 +178,39 @@ public void getRoomList() throws Exception { ); } + @DisplayName("채팅방 나가기 API") + @Test + public void getOutRoom() throws Exception { + //given + var loginInfo = new LoginRequest(); + loginInfo.setNickname("hello"); + + var request = new RoomOutRequest(); + doNothing().when(chatService).getOutRoom(any(), any()); + //when //then + mockMvc.perform(delete("/room") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + .sessionAttr("user", loginInfo) + ).andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.status").value("success") + ).andDo( + document("delete-room", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("id").description("나갈 채팅방의 id") + ), + responseFields( + fieldWithPath("status").description("요청 처리 결과") + ) + ) + ) + ; + + } private Chat createChat(long id, String nickname, String message, Room room, LocalDateTime createdAt) { Chat chat = spy(Chat.builder() .room(room) diff --git a/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java b/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java index 6da9011..4acc440 100644 --- a/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/RoomRepositoryTest.java @@ -4,14 +4,13 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; import jakarta.transaction.Transactional; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import java.util.List; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; class RoomRepositoryTest extends SpringTest { @Autowired diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index bfb8cd9..c823d17 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -4,11 +4,9 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; import com.websocket.demo.repository.ChatRepository; +import com.websocket.demo.repository.RoomInfoRepository; import com.websocket.demo.repository.RoomRepository; -import com.websocket.demo.request.CreateChatRequest; -import com.websocket.demo.request.CreateRoomRequest; -import com.websocket.demo.request.DeleteChatRequest; -import com.websocket.demo.request.FindChatListRequest; +import com.websocket.demo.request.*; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; import com.websocket.demo.response.RoomInfo; @@ -30,11 +28,14 @@ class ChatServiceTest extends SpringTest { ChatRepository chatRepository; @Autowired RoomRepository roomRepository; + @Autowired + RoomInfoRepository roomInfoRepository; @BeforeEach - public void init(){ + public void init() { chatRepository.deleteAllInBatch(); - roomRepository.deleteAll(); + roomInfoRepository.deleteAllInBatch(); + roomRepository.deleteAllInBatch(); } @DisplayName("체팅을 전달 받으면 저장하고 저장된 체팅 정보를 반환한다.") @@ -153,6 +154,37 @@ public void createRoom() { assertThat(room.getChat()).isEmpty(); } + @DisplayName("채팅방을 나간다.") + @Test + public void getOutRoom() { + //given + Long id = saveRoom("room1", "nick", "john").getId(); + + var request = new RoomOutRequest(); + request.setId(id); + var nickname = "nick"; + //when + chatService.getOutRoom(request, nickname); + //then + assertThat(roomInfoRepository.findAll()).extracting("userNickname", "id") + .doesNotContain(tuple("nick", id)); + } + + @DisplayName("채팅방을 나가고 아무도 없는 채팅방이라면 채팅방을 삭제한다.") + @Test + public void getOutRoomAndRemoveRoom() { + //given + Long id = saveRoom("room1", "nick").getId(); + + var request = new RoomOutRequest(); + request.setId(id); + var nickname = "nick"; + //when + chatService.getOutRoom(request, nickname); + //then + assertThat(roomRepository.findById(id)).isEmpty(); + } + private Chat saveChat(Room room, String sender, String message) { return chatRepository.saveAndFlush(Chat.builder() .message(message) From 4ea4f8972281f350dcb67e657ce42e2a7fa96a79 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Sun, 17 Sep 2023 14:14:47 +0900 Subject: [PATCH 08/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=82=98?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20ws=20=EA=B5=AC=ED=98=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/websocket/demo/api/ChatApi.java | 8 +-- .../controller/ChatWebsocketController.java | 34 ++++++--- .../interceptor/ChatHandshakeInterceptor.java | 17 ++++- .../demo/request/CreateChatRequest.java | 1 + .../demo/response/ChatStompResponse.java | 34 +++++++++ .../com/websocket/demo/response/RoomInfo.java | 25 ++++--- .../websocket/demo/response/RoomUserInfo.java | 16 +++++ .../websocket/demo/service/ChatService.java | 11 +-- src/main/resources/static/app.js | 43 +++++++----- .../com/websocket/demo/api/ChatApiTest.java | 53 ++++---------- .../websocket/demo/integrate/ChatTest.java | 5 +- .../ChatHandshakeInterceptorTest.java | 7 +- .../demo/response/ChatStompResponseTest.java | 70 +++++++++++++++++++ 13 files changed, 234 insertions(+), 90 deletions(-) create mode 100644 src/main/java/com/websocket/demo/response/ChatStompResponse.java create mode 100644 src/main/java/com/websocket/demo/response/RoomUserInfo.java create mode 100644 src/test/java/com/websocket/demo/response/ChatStompResponseTest.java diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ChatApi.java index 9655d48..3975956 100644 --- a/src/main/java/com/websocket/demo/api/ChatApi.java +++ b/src/main/java/com/websocket/demo/api/ChatApi.java @@ -3,13 +3,12 @@ import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.request.LoginRequest; -import com.websocket.demo.request.RoomOutRequest; import com.websocket.demo.response.ApiResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import static com.websocket.demo.response.ApiResponse.*; +import static com.websocket.demo.response.ApiResponse.success; @RestController @RequestMapping @@ -31,9 +30,4 @@ public ApiResponse getRoomList(@SessionAttribute("user") LoginRequest userInfo ) public ApiResponse createRoomList(@RequestBody CreateRoomRequest request){ return success(chatService.createRoom(request)); } - @DeleteMapping("/room") - public ApiResponse getOutRoom(@RequestBody RoomOutRequest request, @SessionAttribute("user") LoginRequest userInfo){ - chatService.getOutRoom(request, userInfo.getNickname()); - return success(null); - } } diff --git a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index d548be9..bc8b954 100644 --- a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -2,12 +2,13 @@ import com.websocket.demo.request.CreateChatRequest; import com.websocket.demo.request.DeleteChatRequest; -import com.websocket.demo.response.ChatInfo; -import com.websocket.demo.response.DeleteChat; +import com.websocket.demo.request.RoomOutRequest; +import com.websocket.demo.response.ChatStompResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @Controller @@ -15,17 +16,32 @@ public class ChatWebsocketController { private final ChatService chatService; + private final SimpMessagingTemplate simpMessagingTemplate; @MessageMapping("/chat/new") - @SendTo("/topic/chat/new") - public ChatInfo newChat(CreateChatRequest request) { - return chatService.createChat(request); + public void newChat(CreateChatRequest request) { + var message = ChatStompResponse.createChat(chatService.createChat(request)); + sendTo(request.getRoomId(), message); } @MessageMapping("/chat/delete") - @SendTo("/topic/chat/delete") - public DeleteChat deleteChat(DeleteChatRequest request) { - return chatService.delete(request); + public void deleteChat(DeleteChatRequest request) { + var message = ChatStompResponse.deleteChat(chatService.delete(request)); + sendTo(request.getRoomId(), message); } + @MessageMapping("/room/out") + public void roomOut(RoomOutRequest request, SimpMessageHeaderAccessor accessor) { + var nickname = (String) accessor.getSessionAttributes().get("nickname"); + var message = ChatStompResponse.getOutRoom(chatService.getOutRoom(request, nickname)); + sendTo(request.getId(), message); + } + + private void sendTo(Long roomId, Object message){ + simpMessagingTemplate.convertAndSend("/topic/chat-" + roomId, message); + } + + private void sendTo(String nickname, Object message){ + simpMessagingTemplate.convertAndSend("/topic/chat-" + nickname, message); + } } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java index e62f554..0b4e2ba 100644 --- a/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java +++ b/src/main/java/com/websocket/demo/interceptor/ChatHandshakeInterceptor.java @@ -17,17 +17,30 @@ public class ChatHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) { - return isLogin((ServletServerHttpRequest) request); + if(isLogin((ServletServerHttpRequest) request)){ + setInformation((ServletServerHttpRequest) request, attributes); + return true; + } + return false; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } - private static boolean isLogin(ServletServerHttpRequest request) { + private boolean isLogin(ServletServerHttpRequest request) { var servletRequest = request; var session = servletRequest.getServletRequest().getSession(); var info = (LoginRequest) session.getAttribute("user"); return info != null; } + + // spring security 적용시 Principal 주입으로 해결 + private void setInformation(ServletServerHttpRequest request, Map attributes){ + var servletRequest = request; + var session = servletRequest.getServletRequest().getSession(); + var info = (LoginRequest) session.getAttribute("user"); + var nickname = info.getNickname(); + attributes.put("nickname", nickname); + } } diff --git a/src/main/java/com/websocket/demo/request/CreateChatRequest.java b/src/main/java/com/websocket/demo/request/CreateChatRequest.java index c19ef4c..a2fcfa2 100644 --- a/src/main/java/com/websocket/demo/request/CreateChatRequest.java +++ b/src/main/java/com/websocket/demo/request/CreateChatRequest.java @@ -1,6 +1,7 @@ package com.websocket.demo.request; import lombok.Data; +import lombok.ToString; @Data public class CreateChatRequest { diff --git a/src/main/java/com/websocket/demo/response/ChatStompResponse.java b/src/main/java/com/websocket/demo/response/ChatStompResponse.java new file mode 100644 index 0000000..1f99389 --- /dev/null +++ b/src/main/java/com/websocket/demo/response/ChatStompResponse.java @@ -0,0 +1,34 @@ +package com.websocket.demo.response; + +import lombok.Data; + +@Data +public class ChatStompResponse { + private String type; + private Object data; + + private ChatStompResponse(String type, Object data) { + this.type = type; + this.data = data; + } + + public static ChatStompResponse createChat(ChatInfo data){ + return new ChatStompResponse("createChat", data); + } + + public static ChatStompResponse deleteChat(DeleteChat data){ + return new ChatStompResponse("deleteChat", data); + } + + public static ChatStompResponse friendComeInRoom(RoomUserInfo data){ + return new ChatStompResponse("friendComeInRoom", data); + } + + public static ChatStompResponse getOutRoom(RoomUserInfo data) { + return new ChatStompResponse("getOutRoom", data); + } + + public static ChatStompResponse readChat(RoomUserInfo data) { + return new ChatStompResponse("readChat", data); + } +} diff --git a/src/main/java/com/websocket/demo/response/RoomInfo.java b/src/main/java/com/websocket/demo/response/RoomInfo.java index 8b6fc51..10413e3 100644 --- a/src/main/java/com/websocket/demo/response/RoomInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomInfo.java @@ -2,13 +2,12 @@ import com.websocket.demo.domain.Room; import com.websocket.demo.domain.RoomUserData; -import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import java.util.List; @Data -@AllArgsConstructor public class RoomInfo { private final Long id; @@ -16,16 +15,24 @@ public class RoomInfo { private final List users; private final List chat; + @Builder + private RoomInfo(Long id, String title, List users, List chat) { + this.id = id; + this.title = title; + this.users = users; + this.chat = chat; + } + public static RoomInfo from(Room room) { - return new RoomInfo( - room.getId(), - room.getTitle(), - room.getData().stream() + return RoomInfo.builder() + .id(room.getId()) + .title(room.getTitle()) + .users(room.getData().stream() .map(RoomUserData::getUserNickname) - .toList(), - room.getChatList().stream() + .toList() + ).chat(room.getChatList().stream() .map(ChatInfo::from) .toList() - ); + ).build(); } } diff --git a/src/main/java/com/websocket/demo/response/RoomUserInfo.java b/src/main/java/com/websocket/demo/response/RoomUserInfo.java new file mode 100644 index 0000000..6bbabe3 --- /dev/null +++ b/src/main/java/com/websocket/demo/response/RoomUserInfo.java @@ -0,0 +1,16 @@ +package com.websocket.demo.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.time.LocalDateTime; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include; + +@Data +public class RoomUserInfo { + private Long roomId; + private String nickname; + @JsonInclude(Include.NON_NULL) + private LocalDateTime time; +} diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index e33b4f3..dcbb2d5 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -6,9 +6,7 @@ import com.websocket.demo.repository.RoomInfoRepository; import com.websocket.demo.repository.RoomRepository; import com.websocket.demo.request.*; -import com.websocket.demo.response.ChatInfo; -import com.websocket.demo.response.DeleteChat; -import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.response.*; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -62,10 +60,15 @@ public RoomInfo createRoom(CreateRoomRequest request) { return RoomInfo.from(roomRepository.save(room)); } - public void getOutRoom(RoomOutRequest request, String nickname) { + public RoomUserInfo getOutRoom(RoomOutRequest request, String nickname) { roomInfoRepository.deleteByUserNicknameAndRoomId(nickname, request.getId()); if(!roomInfoRepository.existsByUserNicknameAndRoomId(nickname, request.getId())){ roomRepository.deleteById(request.getId()); } + + var result = new RoomUserInfo(); + result.setNickname(nickname); + result.setRoomId(result.getRoomId()); + return result; } } diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 6515874..3cc9828 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -5,24 +5,33 @@ const stompClient = new StompJs.Client({ stompClient.onConnect = (frame) => { setConnected(true); console.log('Connected: ' + frame); - stompClient.subscribe('/topic/chat/new', (greeting) => { - const response = JSON.parse(greeting.body); - const sender = response.sender; - const message = response.message; - const roomId = response.roomId; - const id = response.id; - console.log(response); - showGreeting("NEW room : " + roomId + ", from : " + sender + "=(" + id + ": " + message + ")"); - }); - stompClient.subscribe('/topic/chat/delete', (greeting) => { - const response = JSON.parse(greeting.body); - const status = response.status; - const id = response.id; - const roomId = response.roomId; - showGreeting("DELETE " + id + " at " + roomId); + stompClient.subscribe('/topic/chat-1', (res) => { + const response = JSON.parse(res.body); + const type = response.type; + const data = response.data; + if(type === "deleteChat"){ + showGreeting(deleteChatHandle(data)); + } else if(type === "createChat"){ + showGreeting(createChatHandle(data)); + } }); }; +function createChatHandle(data){ + const sender = data.sender; + const message = data.message; + const roomId = data.roomId; + const id = data.id; + return "NEW room : " + roomId + ", from : " + sender + "=(" + id + ": " + message + ")"; +} + +function deleteChatHandle(data){ + const status = data.status; + const id = data.id; + const roomId = data.roomId; + return "DELETE " + id + " at " + roomId; +} + stompClient.onWebSocketError = (error) => { console.error('Error with websocket', error); }; @@ -60,7 +69,7 @@ function sendName() { body: JSON.stringify({ 'message': $("#name").val(), 'sender': "test1", - 'roomId': 10 + 'roomId': 1 }) }); } @@ -70,7 +79,7 @@ function deleteChat() { destination: "/app/chat/delete", body: JSON.stringify({ 'id': $("#id").val(), - 'roomId': 10 + 'roomId': 1 }) }); } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java index 0ebc087..c24c0d2 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -92,12 +92,12 @@ public void createRoom() throws Exception { var request = new CreateRoomRequest(); request.setTitle("room title"); request.setUsers(List.of("user1", "user2", "user3")); - var response = new RoomInfo( - 100L, - "room title", - List.of("user1", "user2", "user3"), - List.of() - ); + var response = RoomInfo.builder() + .id(100L) + .title("room title") + .users(List.of("user1", "user2", "user3")) + .chat(List.of()) + .build(); given(chatService.createRoom(request)).willReturn(response); //when //then String content = mapper.writeValueAsString(request); @@ -144,7 +144,13 @@ public void getRoomList() throws Exception { chatInfo.setMessage("hello"); chatInfo.setCreatedAt(LocalDateTime.of(2000, 12, 12, 12, 12, 12)); - var roomInfo = new RoomInfo(100L, "room title", List.of(), List.of(chatInfo)); + var roomInfo = RoomInfo.builder() + .id(100L) + .title("room title") + .chat(List.of(chatInfo)) + .users(List.of()) + .build(); + given(chatService.findRoomList("nickname")).willReturn(List.of(roomInfo)); //when then mockMvc.perform(get("/room").sessionAttr("user", info)) @@ -178,39 +184,6 @@ public void getRoomList() throws Exception { ); } - @DisplayName("채팅방 나가기 API") - @Test - public void getOutRoom() throws Exception { - //given - var loginInfo = new LoginRequest(); - loginInfo.setNickname("hello"); - - var request = new RoomOutRequest(); - doNothing().when(chatService).getOutRoom(any(), any()); - //when //then - mockMvc.perform(delete("/room") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON) - .sessionAttr("user", loginInfo) - ).andDo(print()) - .andExpectAll( - status().isOk(), - jsonPath("$.status").value("success") - ).andDo( - document("delete-room", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - requestFields( - fieldWithPath("id").description("나갈 채팅방의 id") - ), - responseFields( - fieldWithPath("status").description("요청 처리 결과") - ) - ) - ) - ; - - } private Chat createChat(long id, String nickname, String message, Room room, LocalDateTime createdAt) { Chat chat = spy(Chat.builder() .room(room) diff --git a/src/test/java/com/websocket/demo/integrate/ChatTest.java b/src/test/java/com/websocket/demo/integrate/ChatTest.java index 6aaf8a6..0aea928 100644 --- a/src/test/java/com/websocket/demo/integrate/ChatTest.java +++ b/src/test/java/com/websocket/demo/integrate/ChatTest.java @@ -7,6 +7,7 @@ import com.websocket.demo.response.RoomInfo; import com.websocket.demo.service.ChatService; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -51,11 +52,13 @@ public void setup() { } @DisplayName("체팅 요청을 보내면 브로드 케스팅된 체팅 데이터를 읽는다.") + @Disabled @Test public void getGreeting() throws Exception { given(chatHandshakeInterceptor.beforeHandshake(any(), any(), any(), any())) .willReturn(true); + var createRoomRequest = new CreateRoomRequest(); createRoomRequest.setTitle("welcome"); createRoomRequest.setUsers(List.of("john")); @@ -66,7 +69,7 @@ public void getGreeting() throws Exception { @Override public void afterConnected(final StompSession session, StompHeaders connectedHeaders) { - session.subscribe("/topic/chat/new", new StompFrameHandler() { + session.subscribe("/topic/chat-" + room.getId(), new StompFrameHandler() { @Override public Type getPayloadType(StompHeaders headers) { return CreateChatRequest.class; diff --git a/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java b/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java index 1fd3b0b..507275f 100644 --- a/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java +++ b/src/test/java/com/websocket/demo/interceptor/ChatHandshakeInterceptorTest.java @@ -9,7 +9,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.server.ServletServerHttpRequest; +import java.util.HashMap; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.atIndex; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; @@ -29,11 +32,13 @@ public void beforeHandshakeWhenLogin() { given(request.getServletRequest()).willReturn(servletRequest); given(servletRequest.getSession()).willReturn(session); given(session.getAttribute("user")).willReturn(loginInfo); + var map = new HashMap(); //when - boolean isSuccess = new ChatHandshakeInterceptor().beforeHandshake(request, null, null, null); + boolean isSuccess = new ChatHandshakeInterceptor().beforeHandshake(request, null, null, map); //then assertThat(isSuccess).isTrue(); + assertThat(map.get("nickname")).isEqualTo("hello"); } @DisplayName("로그인 상태가 아니라면 false를 반환하여 이후 절차를 진행하지 않는다.") diff --git a/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java new file mode 100644 index 0000000..65aa51b --- /dev/null +++ b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java @@ -0,0 +1,70 @@ +package com.websocket.demo.response; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatStompResponseTest { + + @DisplayName("웹소켓 채팅 생성 성공 응답 생성") + @Test + public void createChat() { + //given + var chatInfo = new ChatInfo(); + //when + ChatStompResponse response = ChatStompResponse.createChat(chatInfo); + //then + assertThat(response).extracting("type", "data") + .containsExactly("createChat", chatInfo); + } + + @DisplayName("웹소켓 채팅 삭제 성공 응답 생성") + @Test + public void deleteChat() { + //given + var data = new DeleteChat(100L, 1L); + //when + ChatStompResponse response = ChatStompResponse.deleteChat(data); + //then + assertThat(response).extracting("type", "data") + .containsExactly("deleteChat", data); + } + + @DisplayName("웹소켓 채팅방 친구 초대 성공 응답 생성") + @Test + public void addFriend() { + //given + var data = new RoomUserInfo(); + //when + ChatStompResponse response = ChatStompResponse.friendComeInRoom(data); + //then + assertThat(response).extracting("type", "data") + .containsExactly("friendComeInRoom", data); + } + + @DisplayName("웹소켓 유저 채팅방 나기기 성공 응답 생성") + @Test + public void getOutRoom() { + //given + var data = new RoomUserInfo(); + //when + ChatStompResponse response = ChatStompResponse.getOutRoom(data); + //then + assertThat(response).extracting("type", "data") + .containsExactly("getOutRoom", data); + } + + @DisplayName("웹소켓 채팅 읽기 성공 응답") + @Test + public void readChat() { + //given + var data = new RoomUserInfo(); + //when + ChatStompResponse response = ChatStompResponse.readChat(data); + //then + assertThat(response).extracting("type", "data") + .containsExactly("readChat", data); + } + +} \ No newline at end of file From 9b054958a3d974e7be4a4018c42fa6f7b5b4827e Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Sun, 17 Sep 2023 15:19:09 +0900 Subject: [PATCH 09/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=B9=9C?= =?UTF-8?q?=EA=B5=AC=20=EC=B4=88=EB=8C=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatWebsocketController.java | 9 +++ .../java/com/websocket/demo/domain/Room.java | 13 ++++- .../demo/request/InViteUserRequest.java | 11 ++++ .../demo/response/ChatStompResponse.java | 2 +- .../com/websocket/demo/response/RoomInfo.java | 14 +++++ .../websocket/demo/response/RoomUserInfo.java | 9 +++ .../websocket/demo/service/ChatService.java | 12 +++- .../com/websocket/demo/domain/RoomTest.java | 28 +++++++++ .../demo/response/ChatStompResponseTest.java | 2 +- .../websocket/demo/response/RoomInfoTest.java | 26 +++++++++ .../demo/response/RoomUserInfoTest.java | 39 +++++++++++++ .../demo/service/ChatServiceTest.java | 57 +++++++++++++++++++ 12 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/websocket/demo/request/InViteUserRequest.java create mode 100644 src/test/java/com/websocket/demo/response/RoomUserInfoTest.java diff --git a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index bc8b954..8cc0f36 100644 --- a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -2,6 +2,7 @@ import com.websocket.demo.request.CreateChatRequest; import com.websocket.demo.request.DeleteChatRequest; +import com.websocket.demo.request.InViteUserRequest; import com.websocket.demo.request.RoomOutRequest; import com.websocket.demo.response.ChatStompResponse; import com.websocket.demo.service.ChatService; @@ -37,6 +38,14 @@ public void roomOut(RoomOutRequest request, SimpMessageHeaderAccessor accessor) sendTo(request.getId(), message); } + @MessageMapping("/room/in") + public void roomOut(InViteUserRequest request, SimpMessageHeaderAccessor accessor) { + var host = (String) accessor.getSessionAttributes().get("nickname"); + var message = ChatStompResponse.friendComeInRoom(chatService.inviteUser(request, host)); + sendTo(request.getRoomId(), message); + sendTo(request.getNickname(), message); + } + private void sendTo(Long roomId, Object message){ simpMessagingTemplate.convertAndSend("/topic/chat-" + roomId, message); } diff --git a/src/main/java/com/websocket/demo/domain/Room.java b/src/main/java/com/websocket/demo/domain/Room.java index b53c8f8..0680779 100644 --- a/src/main/java/com/websocket/demo/domain/Room.java +++ b/src/main/java/com/websocket/demo/domain/Room.java @@ -31,11 +31,18 @@ private Room(String title) { this.title = title; } - public void addUser(String userNickname) { - data.add(RoomUserData.builder() + public RoomUserData addUser(String userNickname) { + RoomUserData entity = RoomUserData.builder() .room(this) .backgroundColor("white") .userNickname(userNickname) - .build()); + .build(); + data.add(entity); + return entity; + } + + public boolean containsUser(String host) { + return data.stream().map(RoomUserData::getUserNickname) + .anyMatch(user -> user.equals(host)); } } diff --git a/src/main/java/com/websocket/demo/request/InViteUserRequest.java b/src/main/java/com/websocket/demo/request/InViteUserRequest.java new file mode 100644 index 0000000..b771215 --- /dev/null +++ b/src/main/java/com/websocket/demo/request/InViteUserRequest.java @@ -0,0 +1,11 @@ +package com.websocket.demo.request; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class InViteUserRequest { + private Long roomId; + private String nickname; +} diff --git a/src/main/java/com/websocket/demo/response/ChatStompResponse.java b/src/main/java/com/websocket/demo/response/ChatStompResponse.java index 1f99389..435a2a0 100644 --- a/src/main/java/com/websocket/demo/response/ChatStompResponse.java +++ b/src/main/java/com/websocket/demo/response/ChatStompResponse.java @@ -20,7 +20,7 @@ public static ChatStompResponse deleteChat(DeleteChat data){ return new ChatStompResponse("deleteChat", data); } - public static ChatStompResponse friendComeInRoom(RoomUserInfo data){ + public static ChatStompResponse friendComeInRoom(RoomInfo data){ return new ChatStompResponse("friendComeInRoom", data); } diff --git a/src/main/java/com/websocket/demo/response/RoomInfo.java b/src/main/java/com/websocket/demo/response/RoomInfo.java index 10413e3..1e75cbe 100644 --- a/src/main/java/com/websocket/demo/response/RoomInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomInfo.java @@ -1,5 +1,6 @@ package com.websocket.demo.response; +import com.fasterxml.jackson.annotation.JsonInclude; import com.websocket.demo.domain.Room; import com.websocket.demo.domain.RoomUserData; import lombok.Builder; @@ -7,11 +8,14 @@ import java.util.List; +import static com.fasterxml.jackson.annotation.JsonInclude.*; + @Data public class RoomInfo { private final Long id; private final String title; + @JsonInclude(Include.NON_NULL) private final List users; private final List chat; @@ -35,4 +39,14 @@ public static RoomInfo from(Room room) { .toList() ).build(); } + + public static RoomInfo fromWithoutChat(Room room) { + return RoomInfo.builder() + .id(room.getId()) + .title(room.getTitle()) + .users(room.getData().stream() + .map(RoomUserData::getUserNickname) + .toList() + ).build(); + } } diff --git a/src/main/java/com/websocket/demo/response/RoomUserInfo.java b/src/main/java/com/websocket/demo/response/RoomUserInfo.java index 6bbabe3..c4a19ef 100644 --- a/src/main/java/com/websocket/demo/response/RoomUserInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomUserInfo.java @@ -1,6 +1,7 @@ package com.websocket.demo.response; import com.fasterxml.jackson.annotation.JsonInclude; +import com.websocket.demo.domain.RoomUserData; import lombok.Data; import java.time.LocalDateTime; @@ -13,4 +14,12 @@ public class RoomUserInfo { private String nickname; @JsonInclude(Include.NON_NULL) private LocalDateTime time; + + public static RoomUserInfo from(RoomUserData data) { + var result = new RoomUserInfo(); + result.setTime(data.getCheckTime()); + result.setRoomId(data.getRoom().getId()); + result.setNickname(data.getUserNickname()); + return result; + } } diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index dcbb2d5..98a6b3e 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -2,6 +2,7 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; +import com.websocket.demo.domain.RoomUserData; import com.websocket.demo.repository.ChatRepository; import com.websocket.demo.repository.RoomInfoRepository; import com.websocket.demo.repository.RoomRepository; @@ -60,9 +61,10 @@ public RoomInfo createRoom(CreateRoomRequest request) { return RoomInfo.from(roomRepository.save(room)); } + public RoomUserInfo getOutRoom(RoomOutRequest request, String nickname) { roomInfoRepository.deleteByUserNicknameAndRoomId(nickname, request.getId()); - if(!roomInfoRepository.existsByUserNicknameAndRoomId(nickname, request.getId())){ + if (!roomInfoRepository.existsByUserNicknameAndRoomId(nickname, request.getId())) { roomRepository.deleteById(request.getId()); } @@ -71,4 +73,12 @@ public RoomUserInfo getOutRoom(RoomOutRequest request, String nickname) { result.setRoomId(result.getRoomId()); return result; } + + public RoomInfo inviteUser(InViteUserRequest request, String host) { + var room = roomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new IllegalArgumentException("해당 채팅방은 존재하지 않습니다.")); + if (!room.containsUser(host)) throw new IllegalArgumentException("해당 유저는 초대 권한이 없습니다."); + room.addUser(request.getNickname()); + return RoomInfo.fromWithoutChat(room); + } } diff --git a/src/test/java/com/websocket/demo/domain/RoomTest.java b/src/test/java/com/websocket/demo/domain/RoomTest.java index 20678d1..fbf1ed8 100644 --- a/src/test/java/com/websocket/demo/domain/RoomTest.java +++ b/src/test/java/com/websocket/demo/domain/RoomTest.java @@ -27,4 +27,32 @@ public void addUser() { ); } + @DisplayName("닉네임으로 유저 추가시 해당 유저의 채팅방 설정 정보를 반환한다.") + @Test + public void addUserReturn() { + //given + Room room = Room.builder() + .title("room1") + .build(); + //when + RoomUserData data = room.addUser("nick"); + //then + assertThat(data).extracting("room", "backgroundColor", "userNickname") + .containsExactly(room, "white", "nick"); + } + + @DisplayName("유저가 채팅방에 소속되어있는 여부를 반환한다.") + @Test + public void containsUser() { + //given + Room room = Room.builder() + .title("room1") + .build(); + room.addUser("nick"); + room.addUser("ann"); + //when //then + assertThat(room.containsUser("nick")).isTrue(); + assertThat(room.containsUser("hong")).isFalse(); + } + } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java index 65aa51b..39e2df8 100644 --- a/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java +++ b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java @@ -35,7 +35,7 @@ public void deleteChat() { @Test public void addFriend() { //given - var data = new RoomUserInfo(); + var data = RoomInfo.builder().build(); //when ChatStompResponse response = ChatStompResponse.friendComeInRoom(data); //then diff --git a/src/test/java/com/websocket/demo/response/RoomInfoTest.java b/src/test/java/com/websocket/demo/response/RoomInfoTest.java index 00e62c6..bfb12d9 100644 --- a/src/test/java/com/websocket/demo/response/RoomInfoTest.java +++ b/src/test/java/com/websocket/demo/response/RoomInfoTest.java @@ -58,6 +58,32 @@ public void convertRoomInfoInfo() { assertThat(roomInfo.getUsers()).contains("john", "nick"); } + @DisplayName("채팅방의 정보를 담은 객체를 생성시 채팅을 제외한 모든 정보가 일치해야한다.") + @Test + public void convertRoomInfoInfoWithOutChat() { + //given + var room = spy(Room.builder() + .title("rooms") + .build()); + + List roomUserDataList = List.of( + createRoomData(room, "nick", "white", 10L), + createRoomData(room, "john", "white", 11L) + ); + given(room.getId()).willReturn(100L); + given(room.getData()).willReturn(List.of()); + given(room.getData()).willReturn(roomUserDataList); + //when + var roomInfo = RoomInfo.fromWithoutChat(room); + //then + assertThat(roomInfo) + .extracting("id", "title") + .containsExactly(100L, "rooms"); + + assertThat(roomInfo.getChat()).isNull(); + assertThat(roomInfo.getUsers()).contains("john", "nick"); + } + private static RoomUserData createRoomData(Room room, String nickname, String background, long id) { RoomUserData result = spy(RoomUserData.builder() .room(room) diff --git a/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java b/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java new file mode 100644 index 0000000..e267c57 --- /dev/null +++ b/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java @@ -0,0 +1,39 @@ +package com.websocket.demo.response; + +import com.websocket.demo.domain.Room; +import com.websocket.demo.domain.RoomUserData; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.Mockito.spy; + +class RoomUserInfoTest { + + @DisplayName("채팅방 정보가 info 객체로 정확하게 전달된다.") + @Test + public void createRoomUserInfoFromEntity() { + //given + var id = 100L; + var time = LocalDateTime.of(2000, 10, 11, 1, 23, 40); + var room = mock(Room.class); + + RoomUserData data = spy(RoomUserData.builder() + .room(room) + .userNickname("nick") + .backgroundColor("white") + .build()); + + given(room.getId()).willReturn(id); + given(data.getCheckTime()).willReturn(time); + //when + RoomUserInfo from = RoomUserInfo.from(data); + //then + Assertions.assertThat(from).extracting("roomId", "nickname", "time") + .containsExactly(id, "nick", time); + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index c823d17..0c473a7 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -10,6 +10,8 @@ import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.DeleteChat; import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.response.RoomUserInfo; +import jakarta.transaction.Transactional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -185,6 +187,61 @@ public void getOutRoomAndRemoveRoom() { assertThat(roomRepository.findById(id)).isEmpty(); } + @DisplayName("없는 채팅방에 초대하면 예외가 발생한다.") + @Test + public void inviteUserFakeRoom() { + //given + var request = new InViteUserRequest(); + request.setRoomId(100L); + //when //then + assertThatThrownBy(() -> chatService.inviteUser(request, "kun")); + } + + @DisplayName("채팅방에 소속되지 않는 유저가 초대하면 예외가 발생한다.") + @Test + public void inviteUserStrangeHost() { + //given + Room room = saveRoom("room1", "kim"); + var request = new InViteUserRequest(); + request.setRoomId(room.getId()); + request.setNickname("kun"); + //when //then + assertThatThrownBy(() -> chatService.inviteUser(request, "cul")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("초대 유저가 null 이라면 예외가 발생한다.") + @Test + public void inviteUserHostNull() { + //given + Room room = saveRoom("room1", "kim"); + var request = new InViteUserRequest(); + request.setRoomId(room.getId()); + request.setNickname("kun"); + //when //then + assertThatThrownBy(() -> chatService.inviteUser(request, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Transactional + @DisplayName("채팅방 초대 요청시 성공한다") + @Test + public void inviteUser() { + //given + Room room = saveRoom("room1", "kim"); + var request = new InViteUserRequest(); + request.setRoomId(room.getId()); + request.setNickname("kun"); + //when + RoomInfo result = chatService.inviteUser(request, "kim"); + var savedRoom = roomRepository.findById(room.getId()).get(); + //then + assertThat(result.getId()).isEqualTo(room.getId()); + assertThat(result.getUsers()).contains("kun", "kim"); + assertThat(savedRoom.containsUser("kun")).isTrue(); + assertThat(savedRoom.containsUser("kim")).isTrue(); + } + private Chat saveChat(Room room, String sender, String message) { return chatRepository.saveAndFlush(Chat.builder() .message(message) From b7545dde1ac3b683e7228fb51c17a65016231df6 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Mon, 18 Sep 2023 10:35:31 +0900 Subject: [PATCH 10/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=EC=83=89=20=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/websocket/demo/api/ChatApi.java | 11 +++- .../controller/ChatWebsocketController.java | 5 +- .../websocket/demo/domain/RoomUserData.java | 12 ++++- .../demo/repository/RoomInfoRepository.java | 4 ++ .../demo/request/UpdateRoomConfigRequest.java | 11 ++++ .../websocket/demo/response/ApiResponse.java | 4 +- .../demo/response/ChatStompResponse.java | 6 +-- .../websocket/demo/response/RoomUserInfo.java | 2 + .../websocket/demo/service/ChatService.java | 17 ++++-- .../com/websocket/demo/api/ChatApiTest.java | 53 ++++++++++++++++++- .../demo/domain/RoomUserDataTest.java | 37 +++++++++++++ .../com/websocket/demo/domain/UserTest.java | 10 ++++ .../demo/response/RoomUserInfoTest.java | 6 +-- .../demo/service/ChatServiceTest.java | 53 +++++++++++++++++++ 14 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/websocket/demo/request/UpdateRoomConfigRequest.java create mode 100644 src/test/java/com/websocket/demo/domain/RoomUserDataTest.java diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ChatApi.java index 3975956..90f1229 100644 --- a/src/main/java/com/websocket/demo/api/ChatApi.java +++ b/src/main/java/com/websocket/demo/api/ChatApi.java @@ -3,6 +3,7 @@ import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.FindChatListRequest; import com.websocket.demo.request.LoginRequest; +import com.websocket.demo.request.UpdateRoomConfigRequest; import com.websocket.demo.response.ApiResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; @@ -23,11 +24,17 @@ public ApiResponse getChattingList(@ModelAttribute FindChatListRequest request) } @GetMapping("/room") - public ApiResponse getRoomList(@SessionAttribute("user") LoginRequest userInfo ) { + public ApiResponse getRoomList(@SessionAttribute("user") LoginRequest userInfo) { return success(chatService.findRoomList(userInfo.getNickname())); } + @PostMapping("/room") - public ApiResponse createRoomList(@RequestBody CreateRoomRequest request){ + public ApiResponse createRoomList(@RequestBody CreateRoomRequest request) { return success(chatService.createRoom(request)); } + + @PutMapping("/room") + public ApiResponse updateRoomConfig(@RequestBody UpdateRoomConfigRequest request, @SessionAttribute("user") LoginRequest userInfo) { + return success(chatService.updateRoom(request, userInfo.getNickname())); + } } diff --git a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index 8cc0f36..f221e90 100644 --- a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -1,9 +1,6 @@ package com.websocket.demo.controller; -import com.websocket.demo.request.CreateChatRequest; -import com.websocket.demo.request.DeleteChatRequest; -import com.websocket.demo.request.InViteUserRequest; -import com.websocket.demo.request.RoomOutRequest; +import com.websocket.demo.request.*; import com.websocket.demo.response.ChatStompResponse; import com.websocket.demo.service.ChatService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/websocket/demo/domain/RoomUserData.java b/src/main/java/com/websocket/demo/domain/RoomUserData.java index 9038d04..4341551 100644 --- a/src/main/java/com/websocket/demo/domain/RoomUserData.java +++ b/src/main/java/com/websocket/demo/domain/RoomUserData.java @@ -16,9 +16,9 @@ public class RoomUserData { @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id") + @JoinColumn(name = "room_id", updatable = false) private Room room; - @Column + @Column(updatable = false) private String userNickname; @Column private LocalDateTime checkTime; @@ -32,4 +32,12 @@ private RoomUserData(Room room, String userNickname, String backgroundColor) { this.checkTime = LocalDateTime.now(); this.backgroundColor = backgroundColor; } + + public void setCheckTime(LocalDateTime checkTime) { + this.checkTime = checkTime; + } + + public void setBackgroundColor(String backgroundColor) { + this.backgroundColor = backgroundColor; + } } diff --git a/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java index e22f62b..bb024d2 100644 --- a/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java +++ b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java @@ -3,8 +3,12 @@ import com.websocket.demo.domain.RoomUserData; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RoomInfoRepository extends JpaRepository { void deleteByUserNicknameAndRoomId(String nickname, Long roomId); boolean existsByUserNicknameAndRoomId(String nickname, Long roomId); + + Optional findByUserNicknameAndRoomId(String nickname, Long roomId); } diff --git a/src/main/java/com/websocket/demo/request/UpdateRoomConfigRequest.java b/src/main/java/com/websocket/demo/request/UpdateRoomConfigRequest.java new file mode 100644 index 0000000..c5f2529 --- /dev/null +++ b/src/main/java/com/websocket/demo/request/UpdateRoomConfigRequest.java @@ -0,0 +1,11 @@ +package com.websocket.demo.request; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class UpdateRoomConfigRequest { + private Long roomId; + private String backgroundColor; +} diff --git a/src/main/java/com/websocket/demo/response/ApiResponse.java b/src/main/java/com/websocket/demo/response/ApiResponse.java index 872fba2..7762379 100644 --- a/src/main/java/com/websocket/demo/response/ApiResponse.java +++ b/src/main/java/com/websocket/demo/response/ApiResponse.java @@ -4,9 +4,9 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; -@Data +@Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public class ApiResponse { diff --git a/src/main/java/com/websocket/demo/response/ChatStompResponse.java b/src/main/java/com/websocket/demo/response/ChatStompResponse.java index 435a2a0..419af58 100644 --- a/src/main/java/com/websocket/demo/response/ChatStompResponse.java +++ b/src/main/java/com/websocket/demo/response/ChatStompResponse.java @@ -1,8 +1,8 @@ package com.websocket.demo.response; -import lombok.Data; +import lombok.Getter; -@Data +@Getter public class ChatStompResponse { private String type; private Object data; @@ -31,4 +31,4 @@ public static ChatStompResponse getOutRoom(RoomUserInfo data) { public static ChatStompResponse readChat(RoomUserInfo data) { return new ChatStompResponse("readChat", data); } -} +} \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/response/RoomUserInfo.java b/src/main/java/com/websocket/demo/response/RoomUserInfo.java index c4a19ef..36b5042 100644 --- a/src/main/java/com/websocket/demo/response/RoomUserInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomUserInfo.java @@ -12,6 +12,7 @@ public class RoomUserInfo { private Long roomId; private String nickname; + private String backgroundColor; @JsonInclude(Include.NON_NULL) private LocalDateTime time; @@ -20,6 +21,7 @@ public static RoomUserInfo from(RoomUserData data) { result.setTime(data.getCheckTime()); result.setRoomId(data.getRoom().getId()); result.setNickname(data.getUserNickname()); + result.setBackgroundColor(data.getBackgroundColor()); return result; } } diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index 98a6b3e..ec8395b 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -2,15 +2,17 @@ import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; -import com.websocket.demo.domain.RoomUserData; import com.websocket.demo.repository.ChatRepository; import com.websocket.demo.repository.RoomInfoRepository; import com.websocket.demo.repository.RoomRepository; import com.websocket.demo.request.*; -import com.websocket.demo.response.*; -import jakarta.transaction.Transactional; +import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.response.DeleteChat; +import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.response.RoomUserInfo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -40,6 +42,7 @@ public DeleteChat delete(DeleteChatRequest request) { return new DeleteChat(request.getRoomId(), request.getId()); } + @Transactional(readOnly = true) public List findChatList(FindChatListRequest req) { return chatRepository.findByRoomIdAndCreatedAtBetween( req.getRoomId(), req.getFrom(), req.getTo() @@ -48,6 +51,7 @@ public List findChatList(FindChatListRequest req) { .toList(); } + @Transactional(readOnly = true) public List findRoomList(String nickname) { List rooms = roomRepository.findByDataUserNickname(nickname); return rooms.stream().map(RoomInfo::from).toList(); @@ -81,4 +85,11 @@ public RoomInfo inviteUser(InViteUserRequest request, String host) { room.addUser(request.getNickname()); return RoomInfo.fromWithoutChat(room); } + + public RoomUserInfo updateRoom(UpdateRoomConfigRequest request, String nickname) { + var config = roomInfoRepository.findByUserNicknameAndRoomId(nickname, request.getRoomId()) + .orElseThrow(() -> new IllegalArgumentException("잘못되 요청입니다.")); + config.setBackgroundColor(request.getBackgroundColor()); + return RoomUserInfo.from(config); + } } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java index c24c0d2..e5220c1 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -7,8 +7,10 @@ import com.websocket.demo.request.CreateRoomRequest; import com.websocket.demo.request.LoginRequest; import com.websocket.demo.request.RoomOutRequest; +import com.websocket.demo.request.UpdateRoomConfigRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.response.RoomUserInfo; import com.websocket.demo.service.ChatService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,6 +23,7 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.refEq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.spy; import static org.mockito.Mockito.doNothing; @@ -153,7 +156,9 @@ public void getRoomList() throws Exception { given(chatService.findRoomList("nickname")).willReturn(List.of(roomInfo)); //when then - mockMvc.perform(get("/room").sessionAttr("user", info)) + mockMvc.perform(get("/room") + .sessionAttr("user", info) + ) .andDo(print()) .andExpectAll( status().isOk(), @@ -184,6 +189,52 @@ public void getRoomList() throws Exception { ); } + @DisplayName("채팅방 배경색 변경 API") + @Test + public void changeBackgroundColor() throws Exception { + //given + var info = new LoginRequest(); + info.setNickname("mark"); + + var request = new UpdateRoomConfigRequest(); + request.setBackgroundColor("blue"); + request.setRoomId(100L); + + var response = new RoomUserInfo(); + response.setNickname("mark"); + response.setRoomId(100L); + response.setBackgroundColor("blue"); + given(chatService.updateRoom(any(), any())).willReturn(response); + //when //then + mockMvc.perform(put("/room") + .sessionAttr("user", info) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.status").value("success"), + jsonPath("$.data.roomId").value("100"), + jsonPath("$.data.backgroundColor").value("blue"), + jsonPath("$.data.nickname").value("mark") + ).andDo( + document("put-change-room-setting", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("roomId").description("채팅방 id"), + fieldWithPath("backgroundColor").description("채팅방 배경색") + ), + responseFields( + fieldWithPath("status").description("요청 처리 결과"), + fieldWithPath("data.roomId").description("채팅방 id"), + fieldWithPath("data.nickname").description("유저 닉네임"), + fieldWithPath("data.backgroundColor").description("채팅방 배경색") + ) + ) + ); + } + private Chat createChat(long id, String nickname, String message, Room room, LocalDateTime createdAt) { Chat chat = spy(Chat.builder() .room(room) diff --git a/src/test/java/com/websocket/demo/domain/RoomUserDataTest.java b/src/test/java/com/websocket/demo/domain/RoomUserDataTest.java new file mode 100644 index 0000000..b291859 --- /dev/null +++ b/src/test/java/com/websocket/demo/domain/RoomUserDataTest.java @@ -0,0 +1,37 @@ +package com.websocket.demo.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoomUserDataTest { + + @DisplayName("채팅방 배경색 변경이 반영된다.") + @ParameterizedTest + @ValueSource(strings = {"blue", "white", "red", "green"}) + public void setBackground(String color) { + //given + var entity = RoomUserData.builder().build(); + //when + entity.setBackgroundColor(color); + //then + assertThat(entity.getBackgroundColor()).isEqualTo(color); + } + + @DisplayName("확인 시간을 변경할 수 있다.") + @Test + public void setTime() { + //given + var entity = RoomUserData.builder().build(); + var time = LocalDateTime.now(); + //when + entity.setCheckTime(time); + //then + assertThat(entity.getCheckTime()).isEqualTo(time); + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/domain/UserTest.java b/src/test/java/com/websocket/demo/domain/UserTest.java index a9537a7..8c528de 100644 --- a/src/test/java/com/websocket/demo/domain/UserTest.java +++ b/src/test/java/com/websocket/demo/domain/UserTest.java @@ -55,4 +55,14 @@ public void matchLoginWhenFailCaseUserNotFound() { //when //then assertThat(user.match(request)).isFalse(); } + + @DisplayName("유저의 닉네임(PK)이 같다면 같은 객체라고 판단한다.") + @Test + public void equals() { + //given + var user1 = new User("test1324", "password2"); + var user2 = new User("test1324", "password1"); + //when //then + assertThat(user1).isEqualTo(user2); + } } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java b/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java index e267c57..696ec67 100644 --- a/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java +++ b/src/test/java/com/websocket/demo/response/RoomUserInfoTest.java @@ -2,12 +2,12 @@ import com.websocket.demo.domain.Room; import com.websocket.demo.domain.RoomUserData; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; import static org.mockito.Mockito.spy; @@ -33,7 +33,7 @@ public void createRoomUserInfoFromEntity() { //when RoomUserInfo from = RoomUserInfo.from(data); //then - Assertions.assertThat(from).extracting("roomId", "nickname", "time") - .containsExactly(id, "nick", time); + assertThat(from).extracting("roomId", "nickname", "time", "backgroundColor") + .containsExactly(id, "nick", time, "white"); } } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index 0c473a7..b7b7f67 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -15,10 +15,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -242,6 +245,56 @@ public void inviteUser() { assertThat(savedRoom.containsUser("kim")).isTrue(); } + @ParameterizedTest + @DisplayName("채팅방 설정을 변경한다.") + @ValueSource(strings = {"blue", "white", "red", "green"}) + public void updateRoom(String color) { + //given + Long id = saveRoom("test", "mark").getId(); + + var request = new UpdateRoomConfigRequest(); + request.setBackgroundColor(color); + request.setRoomId(id); + //when + RoomUserInfo roomUserInfo = chatService.updateRoom(request, "mark"); + //then + assertThat(roomUserInfo).extracting("roomId", "nickname", "backgroundColor") + .containsExactly(id, "mark", color); + assertThat(roomInfoRepository.findByUserNicknameAndRoomId("mark", id).get()) + .extracting("userNickname", "backgroundColor") + .containsExactly("mark", color); + } + + @DisplayName("설정하고자하는 채팅방이 없다면 예외가 발생한다.") + @Test + public void updateRoomThatNotExist() { + //given + Long id = 1L; + String color = "red"; + + var request = new UpdateRoomConfigRequest(); + request.setBackgroundColor(color); + request.setRoomId(id); + //when //then + assertThatThrownBy(() -> chatService.updateRoom(request, "mark")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("채팅방에 유저가소속되지 않다면 예외가 발생한다.") + @Test + public void updateRoomButUserNotValid() { + //given + Long id = saveRoom("test", "mark").getId(); + String color = "blue"; + + var request = new UpdateRoomConfigRequest(); + request.setBackgroundColor(color); + request.setRoomId(id); + //when //then + assertThatThrownBy(() -> chatService.updateRoom(request, "yan")) + .isInstanceOf(IllegalArgumentException.class); + } + private Chat saveChat(Room room, String sender, String message) { return chatRepository.saveAndFlush(Chat.builder() .message(message) From fd7745ba32f02ef817d4ef4917ccd6d4645a4c28 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Mon, 18 Sep 2023 11:08:30 +0900 Subject: [PATCH 11/14] =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatWebsocketController.java | 7 +++ .../demo/request/CheckRoomRequest.java | 13 +++++ .../websocket/demo/response/RoomUserInfo.java | 22 +++++--- .../websocket/demo/service/ChatService.java | 32 ++++++++++-- .../com/websocket/demo/api/ChatApiTest.java | 10 ++-- .../demo/request/CheckRoomRequestTest.java | 18 +++++++ .../demo/response/ChatStompResponseTest.java | 4 +- .../demo/service/ChatServiceTest.java | 51 +++++++++++++++++-- 8 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/websocket/demo/request/CheckRoomRequest.java create mode 100644 src/test/java/com/websocket/demo/request/CheckRoomRequestTest.java diff --git a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index f221e90..60e58e1 100644 --- a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -43,6 +43,13 @@ public void roomOut(InViteUserRequest request, SimpMessageHeaderAccessor accesso sendTo(request.getNickname(), message); } + @MessageMapping("/room/check") + public void readChat(CheckRoomRequest request, SimpMessageHeaderAccessor accessor){ + var host = (String) accessor.getSessionAttributes().get("nickname"); + var message = ChatStompResponse.readChat(chatService.checkRoom(request, host)); + sendTo(request.getRoomId(), message); + } + private void sendTo(Long roomId, Object message){ simpMessagingTemplate.convertAndSend("/topic/chat-" + roomId, message); } diff --git a/src/main/java/com/websocket/demo/request/CheckRoomRequest.java b/src/main/java/com/websocket/demo/request/CheckRoomRequest.java new file mode 100644 index 0000000..d72701a --- /dev/null +++ b/src/main/java/com/websocket/demo/request/CheckRoomRequest.java @@ -0,0 +1,13 @@ +package com.websocket.demo.request; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +public class CheckRoomRequest { + private Long roomId; + private LocalDateTime checkTime = LocalDateTime.now(); +} diff --git a/src/main/java/com/websocket/demo/response/RoomUserInfo.java b/src/main/java/com/websocket/demo/response/RoomUserInfo.java index 36b5042..62e8969 100644 --- a/src/main/java/com/websocket/demo/response/RoomUserInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomUserInfo.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.websocket.demo.domain.RoomUserData; +import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; @@ -12,16 +13,25 @@ public class RoomUserInfo { private Long roomId; private String nickname; + @JsonInclude(Include.NON_NULL) private String backgroundColor; @JsonInclude(Include.NON_NULL) private LocalDateTime time; + @Builder + private RoomUserInfo(Long roomId, String nickname, String backgroundColor, LocalDateTime time) { + this.roomId = roomId; + this.nickname = nickname; + this.backgroundColor = backgroundColor; + this.time = time; + } + public static RoomUserInfo from(RoomUserData data) { - var result = new RoomUserInfo(); - result.setTime(data.getCheckTime()); - result.setRoomId(data.getRoom().getId()); - result.setNickname(data.getUserNickname()); - result.setBackgroundColor(data.getBackgroundColor()); - return result; + return RoomUserInfo.builder() + .time(data.getCheckTime()) + .roomId(data.getRoom().getId()) + .nickname(data.getUserNickname()) + .backgroundColor(data.getBackgroundColor()) + .build(); } } diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index ec8395b..18d4cbc 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -72,24 +72,46 @@ public RoomUserInfo getOutRoom(RoomOutRequest request, String nickname) { roomRepository.deleteById(request.getId()); } - var result = new RoomUserInfo(); - result.setNickname(nickname); - result.setRoomId(result.getRoomId()); - return result; + return RoomUserInfo.builder() + .roomId(request.getId()) + .nickname(nickname) + .build(); } public RoomInfo inviteUser(InViteUserRequest request, String host) { + var room = roomRepository.findById(request.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("해당 채팅방은 존재하지 않습니다.")); + if (!room.containsUser(host)) throw new IllegalArgumentException("해당 유저는 초대 권한이 없습니다."); + room.addUser(request.getNickname()); return RoomInfo.fromWithoutChat(room); } public RoomUserInfo updateRoom(UpdateRoomConfigRequest request, String nickname) { + var config = roomInfoRepository.findByUserNicknameAndRoomId(nickname, request.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("잘못되 요청입니다.")); config.setBackgroundColor(request.getBackgroundColor()); - return RoomUserInfo.from(config); + + return RoomUserInfo.builder() + .roomId(config.getRoom().getId()) + .nickname(config.getUserNickname()) + .backgroundColor(config.getBackgroundColor()) + .build(); + } + + public RoomUserInfo checkRoom(CheckRoomRequest request, String nickname) { + + var config = roomInfoRepository.findByUserNicknameAndRoomId(nickname, request.getRoomId()) + .orElseThrow(() -> new IllegalArgumentException("잘못되 요청입니다.")); + config.setCheckTime(request.getCheckTime()); + + return RoomUserInfo.builder() + .time(config.getCheckTime()) + .roomId(config.getRoom().getId()) + .nickname(config.getUserNickname()) + .build(); } } diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ChatApiTest.java index e5220c1..84ef7c9 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ChatApiTest.java @@ -200,10 +200,12 @@ public void changeBackgroundColor() throws Exception { request.setBackgroundColor("blue"); request.setRoomId(100L); - var response = new RoomUserInfo(); - response.setNickname("mark"); - response.setRoomId(100L); - response.setBackgroundColor("blue"); + var response = RoomUserInfo.builder() + .nickname("mark") + .roomId(100L) + .backgroundColor("blue") + .build(); + given(chatService.updateRoom(any(), any())).willReturn(response); //when //then mockMvc.perform(put("/room") diff --git a/src/test/java/com/websocket/demo/request/CheckRoomRequestTest.java b/src/test/java/com/websocket/demo/request/CheckRoomRequestTest.java new file mode 100644 index 0000000..552a3de --- /dev/null +++ b/src/test/java/com/websocket/demo/request/CheckRoomRequestTest.java @@ -0,0 +1,18 @@ +package com.websocket.demo.request; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CheckRoomRequestTest { + + @DisplayName("시간을 설정하지 않는다면 객체가 생성된 시간을 지닌다.") + @Test + public void initTime() { + //given + var object = new CheckRoomRequest(); + //when //then + assertThat(object.getCheckTime()).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java index 39e2df8..cfb1e9d 100644 --- a/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java +++ b/src/test/java/com/websocket/demo/response/ChatStompResponseTest.java @@ -47,7 +47,7 @@ public void addFriend() { @Test public void getOutRoom() { //given - var data = new RoomUserInfo(); + var data = RoomUserInfo.builder().build(); //when ChatStompResponse response = ChatStompResponse.getOutRoom(data); //then @@ -59,7 +59,7 @@ public void getOutRoom() { @Test public void readChat() { //given - var data = new RoomUserInfo(); + var data = RoomUserInfo.builder().build(); //when ChatStompResponse response = ChatStompResponse.readChat(data); //then diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index b7b7f67..f7403dd 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -21,7 +21,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -258,8 +257,8 @@ public void updateRoom(String color) { //when RoomUserInfo roomUserInfo = chatService.updateRoom(request, "mark"); //then - assertThat(roomUserInfo).extracting("roomId", "nickname", "backgroundColor") - .containsExactly(id, "mark", color); + assertThat(roomUserInfo).extracting("roomId", "nickname", "backgroundColor", "time") + .containsExactly(id, "mark", color, null); assertThat(roomInfoRepository.findByUserNicknameAndRoomId("mark", id).get()) .extracting("userNickname", "backgroundColor") .containsExactly("mark", color); @@ -295,6 +294,52 @@ public void updateRoomButUserNotValid() { .isInstanceOf(IllegalArgumentException.class); } + @DisplayName("채팅방을 확인하면 확인 시간을 갱신하고 그 정보를 반환한다.") + @Test + public void checkRoom() { + //given + Room room = saveRoom("room test", "nick"); + var checkTime = LocalDateTime.now(); + var request = new CheckRoomRequest(); + request.setRoomId(room.getId()); + request.setCheckTime(checkTime); + var host = "nick"; + //when + RoomUserInfo info = chatService.checkRoom(request, host); + //then + assertThat(info).extracting("roomId", "nickname", "time", "backgroundColor") + .containsExactly(room.getId(), "nick", checkTime, null); + } + + @DisplayName("채팅 확인시 채팅방이 존재하지 않는다면 예외가 발생한다.") + @Test + public void checkRoomFail() { + //given + var checkTime = LocalDateTime.now(); + var request = new CheckRoomRequest(); + request.setRoomId(100L); + request.setCheckTime(checkTime); + var host = "nick"; + //when //then + assertThatThrownBy(() -> chatService.checkRoom(request, host)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("채팅확인시 채팅방에 소속되지 않은 유저라면 예외가 발생한다.") + @Test + public void checkRoomFail2() { + //given + var room = saveRoom("test room", "kal"); + var checkTime = LocalDateTime.now(); + var request = new CheckRoomRequest(); + request.setRoomId(room.getId()); + request.setCheckTime(checkTime); + var host = "nick"; + //when //then + assertThatThrownBy(() -> chatService.checkRoom(request, host)) + .isInstanceOf(IllegalArgumentException.class); + } + private Chat saveChat(Room room, String sender, String message) { return chatRepository.saveAndFlush(Chat.builder() .message(message) From 863fcb1bd79565c170e01481574bdd0b1a78421d Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Mon, 18 Sep 2023 12:08:25 +0900 Subject: [PATCH 12/14] =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B9=9C=EA=B5=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.adoc => readme.md | 0 src/docs/asciidoc/index.adoc | 1 + .../com/websocket/demo/domain/Friend.java | 28 ++++++++++--------- .../java/com/websocket/demo/domain/User.java | 13 ++++++--- .../demo/repository/FriendRepository.java | 4 +-- .../websocket/demo/service/UserService.java | 1 + src/main/resources/static/docs/index.html | 10 +++++++ .../com/websocket/demo/domain/FriendTest.java | 9 +++--- .../demo/repository/FriendRepositoryTest.java | 4 +-- .../demo/repository/UserRepositoryTest.java | 2 +- .../demo/service/UserServiceTest.java | 20 ++++++++++++- 11 files changed, 64 insertions(+), 28 deletions(-) rename index.adoc => readme.md (100%) create mode 100644 src/docs/asciidoc/index.adoc create mode 100644 src/main/resources/static/docs/index.html diff --git a/index.adoc b/readme.md similarity index 100% rename from index.adoc rename to readme.md diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/domain/Friend.java b/src/main/java/com/websocket/demo/domain/Friend.java index 244c474..0cced10 100644 --- a/src/main/java/com/websocket/demo/domain/Friend.java +++ b/src/main/java/com/websocket/demo/domain/Friend.java @@ -11,28 +11,30 @@ public class Friend { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; - @Column - private String userNickname; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "friend_nickname") - private User friend; + @JoinColumn(name = "user_nickname", nullable = false) + private User user; + + @Column(nullable = false) + private String friendNickname; @Builder - private Friend(String userNickname, User friend) { - this.userNickname = userNickname; - this.friend = friend; + private Friend(User user, String friend) { + this.user = user; + this.friendNickname= friend; } - public String getUserNickname() { - return userNickname; + public User getUser() { + return user; } - public User getFriend() { - return this.friend; + public String getName() { + return this.friendNickname; } public FriendInfo toInfo() { - return new FriendInfo(getFriend().getNickname()); + return new FriendInfo(getName()); } + + } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/domain/User.java b/src/main/java/com/websocket/demo/domain/User.java index 46e436d..586360d 100644 --- a/src/main/java/com/websocket/demo/domain/User.java +++ b/src/main/java/com/websocket/demo/domain/User.java @@ -20,7 +20,7 @@ public class User { private String nickname; @Column(columnDefinition = "varchar(20)") private String password; - @OneToMany(mappedBy = "friend", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private List friends = new ArrayList<>(); @Builder @@ -32,8 +32,8 @@ public User(String nickname, String password) { public void addFriends(User... friends) { for (var friend : friends) { this.friends.add(Friend.builder() - .userNickname(getNickname()) - .friend(friend) + .user(this) + .friend(friend.getNickname()) .build() ); } @@ -62,11 +62,16 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; - return Objects.equals(nickname, user.nickname); + return Objects.equals(this.nickname, user.nickname); } @Override public int hashCode() { return Objects.hash(nickname); } + + public boolean contains(User user) { + return friends.stream().map(Friend::getName) + .anyMatch(u -> u.equals(user.getNickname())); + } } diff --git a/src/main/java/com/websocket/demo/repository/FriendRepository.java b/src/main/java/com/websocket/demo/repository/FriendRepository.java index c172a4e..8c42057 100644 --- a/src/main/java/com/websocket/demo/repository/FriendRepository.java +++ b/src/main/java/com/websocket/demo/repository/FriendRepository.java @@ -11,10 +11,10 @@ public interface FriendRepository extends JpaRepository { - @EntityGraph(attributePaths = "friend") + @EntityGraph(attributePaths = "user") List findByUserNickname(String nickname); @Modifying(flushAutomatically = true) - @Query("delete from Friend f where f.userNickname=:userNickname and f.friend.nickname=:friendNickname") + @Query("delete from Friend f where f.user.nickname=:userNickname and f.friendNickname=:friendNickname") void deleteByUserNicknameAndFriendNickname(@Param("userNickname") String userNickname,@Param("friendNickname") String friendNickname); } \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/service/UserService.java b/src/main/java/com/websocket/demo/service/UserService.java index 4cff889..1de8e79 100644 --- a/src/main/java/com/websocket/demo/service/UserService.java +++ b/src/main/java/com/websocket/demo/service/UserService.java @@ -41,6 +41,7 @@ public boolean addFriend(AddFriendRequest request, String userNickname) { User user = userRepository.findByNickname(userNickname); User friend = userRepository.findByNickname(request.getNickname()); if (friend == null || user == null) return false; + if(user.contains(friend)) return false; user.addFriends(friend); return true; } diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 0000000..566549b --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/domain/FriendTest.java b/src/test/java/com/websocket/demo/domain/FriendTest.java index 40de4f0..b90a614 100644 --- a/src/test/java/com/websocket/demo/domain/FriendTest.java +++ b/src/test/java/com/websocket/demo/domain/FriendTest.java @@ -16,8 +16,8 @@ public void createByBuilder() { var friend = new User("fri", "1234"); Friend friendInfo = createFriend(user, friend); //when //then - assertThat(friendInfo.getUserNickname()).isEqualTo(user.getNickname()); - assertThat(friendInfo.getFriend()).isEqualTo(friend); + assertThat(friendInfo.getUser()).isEqualTo(user); + assertThat(friendInfo.getName()).isEqualTo(friend.getNickname()); } @DisplayName("친구 정보를 담은 객체를 반환한다.") @@ -36,10 +36,9 @@ public void toInfo() { private Friend createFriend(User user, User friend) { var friendInfo = Friend.builder() - .friend(friend) - .userNickname(user.getNickname()) + .friend(friend.getNickname()) + .user(user) .build(); return friendInfo; } - } \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/repository/FriendRepositoryTest.java b/src/test/java/com/websocket/demo/repository/FriendRepositoryTest.java index 8422821..8b051df 100644 --- a/src/test/java/com/websocket/demo/repository/FriendRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/FriendRepositoryTest.java @@ -30,9 +30,9 @@ public void findByUserNickname() { //when List friends = friendRepository.findByUserNickname(user1.getNickname()); //then - assertThat(friends).extracting("userNickname") + assertThat(friends).extracting("user.nickname") .contains(user1.getNickname()); - assertThat(friends).extracting("friend.nickname") + assertThat(friends).extracting("friendNickname") .contains(user2.getNickname()); } diff --git a/src/test/java/com/websocket/demo/repository/UserRepositoryTest.java b/src/test/java/com/websocket/demo/repository/UserRepositoryTest.java index 8c416a9..64132df 100644 --- a/src/test/java/com/websocket/demo/repository/UserRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/UserRepositoryTest.java @@ -43,7 +43,7 @@ public void getFriendsList() { //when List friends = user.getFriends(); //then - assertThat(friends).extracting("friend.nickname") + assertThat(friends).extracting("friendNickname") .containsExactly(friend1.getNickname(), friend2.getNickname()); } diff --git a/src/test/java/com/websocket/demo/service/UserServiceTest.java b/src/test/java/com/websocket/demo/service/UserServiceTest.java index 644acf4..fb8f8e4 100644 --- a/src/test/java/com/websocket/demo/service/UserServiceTest.java +++ b/src/test/java/com/websocket/demo/service/UserServiceTest.java @@ -114,7 +114,9 @@ public void successCase() { boolean result = userService.addFriend(request, loginUserNickname); //then assertThat(result).isTrue(); - assertThat(userService.friendList(loginUserNickname)).hasSize(1); + assertThat(userService.friendList(loginUserNickname)).hasSize(1) + .extracting("nickname") + .contains("friend1"); } @DisplayName("친구 닉네임이 존재하지 않을때") @@ -141,6 +143,22 @@ public void failCase2() { //then assertThat(result).isFalse(); } + + @DisplayName("중복된 친구를 추가할 때") + @Transactional + @Test + public void duplicatedFriend(){ + //given + var loginUserNickname = "hello"; + var request = new AddFriendRequest(); + request.setNickname("friend1"); + //when + boolean first = userService.addFriend(request, loginUserNickname); + boolean second = userService.addFriend(request, loginUserNickname); + //then + assertThat(first).isTrue(); + assertThat(second).isFalse(); + } } @DisplayName("유저의 친구 목록을 조회할 수 있다.") From 7fc543324ca0b1d6b19c720175668a3b94b2793a Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Wed, 20 Sep 2023 12:01:17 +0900 Subject: [PATCH 13/14] =?UTF-8?q?UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 54 +-- .../api/{ChatApi.java => ApiController.java} | 15 +- .../controller/ChatWebsocketController.java | 7 +- .../demo/controller/PageController.java | 55 +++ .../demo/controller/UserController.java | 8 +- .../demo/repository/RoomInfoRepository.java | 2 +- .../demo/request/CreateChatRequest.java | 3 +- .../demo/request/DeleteFriendRequest.java | 10 + .../demo/request/FindChatListRequest.java | 4 +- .../websocket/demo/response/FriendInfo.java | 2 + .../com/websocket/demo/response/RoomInfo.java | 3 +- .../websocket/demo/service/ChatService.java | 14 +- .../websocket/demo/service/UserService.java | 4 + src/main/resources/application.yml | 10 +- src/main/resources/data.sql | 13 + src/main/resources/static/app.js | 218 +++++++-- src/main/resources/static/index.html | 64 --- src/main/resources/static/main.css | 14 - src/main/resources/static/scroll.js | 11 + src/main/resources/templates/addFriend.html | 98 +++- src/main/resources/templates/createUser.html | 100 +++- src/main/resources/templates/index.html | 429 ++++++++++++++++++ src/main/resources/templates/login.html | 104 ++++- src/main/resources/templates/room.html | 57 +++ .../api/{ChatApiTest.java => ApiTest.java} | 45 +- .../demo/controller/UserControllerTest.java | 8 +- .../demo/repository/ChatRepositoryTest.java | 1 + .../demo/service/ChatServiceTest.java | 60 ++- .../demo/service/UserServiceTest.java | 13 + 29 files changed, 1227 insertions(+), 199 deletions(-) rename src/main/java/com/websocket/demo/api/{ChatApi.java => ApiController.java} (72%) create mode 100644 src/main/java/com/websocket/demo/controller/PageController.java create mode 100644 src/main/java/com/websocket/demo/request/DeleteFriendRequest.java create mode 100644 src/main/resources/data.sql delete mode 100644 src/main/resources/static/index.html delete mode 100644 src/main/resources/static/main.css create mode 100644 src/main/resources/static/scroll.js create mode 100644 src/main/resources/templates/index.html create mode 100644 src/main/resources/templates/room.html rename src/test/java/com/websocket/demo/api/{ChatApiTest.java => ApiTest.java} (88%) diff --git a/build.gradle b/build.gradle index cbfa363..71042f1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,53 +1,53 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.1.3' - id 'io.spring.dependency-management' version '1.1.3' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'java' + id 'org.springframework.boot' version '3.1.3' + id 'io.spring.dependency-management' version '1.1.3' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'com.websocket' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '17' + sourceCompatibility = '17' } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } ext { - set('snippetsDir', file("build/generated-snippets")) + set('snippetsDir', file("build/generated-snippets")) } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-websocket' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' // developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'com.h2database:h2' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() + outputs.dir snippetsDir + useJUnitPlatform() } tasks.named('asciidoctor') { - inputs.dir snippetsDir - dependsOn test -} + inputs.dir snippetsDir + dependsOn test +} \ No newline at end of file diff --git a/src/main/java/com/websocket/demo/api/ChatApi.java b/src/main/java/com/websocket/demo/api/ApiController.java similarity index 72% rename from src/main/java/com/websocket/demo/api/ChatApi.java rename to src/main/java/com/websocket/demo/api/ApiController.java index 90f1229..c7d92e8 100644 --- a/src/main/java/com/websocket/demo/api/ChatApi.java +++ b/src/main/java/com/websocket/demo/api/ApiController.java @@ -1,11 +1,9 @@ package com.websocket.demo.api; -import com.websocket.demo.request.CreateRoomRequest; -import com.websocket.demo.request.FindChatListRequest; -import com.websocket.demo.request.LoginRequest; -import com.websocket.demo.request.UpdateRoomConfigRequest; +import com.websocket.demo.request.*; import com.websocket.demo.response.ApiResponse; import com.websocket.demo.service.ChatService; +import com.websocket.demo.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -14,9 +12,10 @@ @RestController @RequestMapping @RequiredArgsConstructor -public class ChatApi { +public class ApiController { private final ChatService chatService; + private final UserService userService; @GetMapping("/chat") public ApiResponse getChattingList(@ModelAttribute FindChatListRequest request) { @@ -37,4 +36,10 @@ public ApiResponse createRoomList(@RequestBody CreateRoomRequest request) { public ApiResponse updateRoomConfig(@RequestBody UpdateRoomConfigRequest request, @SessionAttribute("user") LoginRequest userInfo) { return success(chatService.updateRoom(request, userInfo.getNickname())); } + + @DeleteMapping("/friend") + public ApiResponse deleteFriend(@RequestBody DeleteFriendRequest request, @SessionAttribute("user") LoginRequest userInfo){ + userService.removeFriendByNickname(userInfo.getNickname(), request.getFriendNickname()); + return success(null); + } } diff --git a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java index 60e58e1..a594cc5 100644 --- a/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java +++ b/src/main/java/com/websocket/demo/controller/ChatWebsocketController.java @@ -23,8 +23,9 @@ public void newChat(CreateChatRequest request) { } @MessageMapping("/chat/delete") - public void deleteChat(DeleteChatRequest request) { - var message = ChatStompResponse.deleteChat(chatService.delete(request)); + public void deleteChat(DeleteChatRequest request, SimpMessageHeaderAccessor accessor) { + var nickname = (String) accessor.getSessionAttributes().get("nickname"); + var message = ChatStompResponse.deleteChat(chatService.deleteChat(request, nickname)); sendTo(request.getRoomId(), message); } @@ -36,7 +37,7 @@ public void roomOut(RoomOutRequest request, SimpMessageHeaderAccessor accessor) } @MessageMapping("/room/in") - public void roomOut(InViteUserRequest request, SimpMessageHeaderAccessor accessor) { + public void roomIn(InViteUserRequest request, SimpMessageHeaderAccessor accessor) { var host = (String) accessor.getSessionAttributes().get("nickname"); var message = ChatStompResponse.friendComeInRoom(chatService.inviteUser(request, host)); sendTo(request.getRoomId(), message); diff --git a/src/main/java/com/websocket/demo/controller/PageController.java b/src/main/java/com/websocket/demo/controller/PageController.java new file mode 100644 index 0000000..1262263 --- /dev/null +++ b/src/main/java/com/websocket/demo/controller/PageController.java @@ -0,0 +1,55 @@ +package com.websocket.demo.controller; + +import com.websocket.demo.request.FindChatListRequest; +import com.websocket.demo.request.LoginRequest; +import com.websocket.demo.response.ChatInfo; +import com.websocket.demo.response.FriendInfo; +import com.websocket.demo.response.RoomInfo; +import com.websocket.demo.service.ChatService; +import com.websocket.demo.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.SessionAttribute; + +import java.util.List; + +@Controller +@RequestMapping +@RequiredArgsConstructor +@Slf4j +public class PageController { + + private final ChatService chatService; + private final UserService userService; + @RequestMapping("/") + public String mainPage(@SessionAttribute(name = "user", required = false) LoginRequest loginInfo, Model model, + @RequestParam(required = false) Long roomId) { + if(loginInfo == null) return "login"; + String nickname = loginInfo.getNickname(); + List roomList = chatService.findRoomList(nickname); + List friends = userService.friendList(nickname); + + FindChatListRequest request = new FindChatListRequest(); + request.setRoomId(roomId); + + List chatList = chatService.findChatList(request); + RoomInfo roomInfo = null; + for (RoomInfo info : roomList) { + if(info.getId().equals(roomId)) { + roomInfo = info; + } + } + + model.addAttribute("nickname", nickname); + model.addAttribute("roomList", roomList); + model.addAttribute("friends", friends); + model.addAttribute("chatList", chatList); + model.addAttribute("targetRoom", roomInfo); + + return "index"; + } +} diff --git a/src/main/java/com/websocket/demo/controller/UserController.java b/src/main/java/com/websocket/demo/controller/UserController.java index 5802bae..941ca13 100644 --- a/src/main/java/com/websocket/demo/controller/UserController.java +++ b/src/main/java/com/websocket/demo/controller/UserController.java @@ -28,7 +28,7 @@ public String loginUser(@ModelAttribute LoginRequest request, HttpServletRequest @PostMapping("/create") public String createUser(@ModelAttribute CreateUserRequest request){ try { - if (userService.create(request)) return "redirect:/"; + if (userService.create(request)) return "login"; } catch (RuntimeException e){ return "createUser"; } @@ -51,6 +51,12 @@ public String loginPage() { return "login"; } + @GetMapping("/logout") + public String logout(HttpServletRequest request){ + request.getSession().setMaxInactiveInterval(0); + return "login"; + } + @GetMapping("/friend") public String addFriendPage() { return "addFriend"; diff --git a/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java index bb024d2..c481ba8 100644 --- a/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java +++ b/src/main/java/com/websocket/demo/repository/RoomInfoRepository.java @@ -8,7 +8,7 @@ public interface RoomInfoRepository extends JpaRepository { void deleteByUserNicknameAndRoomId(String nickname, Long roomId); - boolean existsByUserNicknameAndRoomId(String nickname, Long roomId); + boolean existsByRoomId(Long roomId); Optional findByUserNicknameAndRoomId(String nickname, Long roomId); } diff --git a/src/main/java/com/websocket/demo/request/CreateChatRequest.java b/src/main/java/com/websocket/demo/request/CreateChatRequest.java index a2fcfa2..724c574 100644 --- a/src/main/java/com/websocket/demo/request/CreateChatRequest.java +++ b/src/main/java/com/websocket/demo/request/CreateChatRequest.java @@ -1,12 +1,13 @@ package com.websocket.demo.request; +import jakarta.validation.constraints.NotBlank; import lombok.Data; -import lombok.ToString; @Data public class CreateChatRequest { private String sender; + @NotBlank private String message; private Long roomId; } diff --git a/src/main/java/com/websocket/demo/request/DeleteFriendRequest.java b/src/main/java/com/websocket/demo/request/DeleteFriendRequest.java new file mode 100644 index 0000000..755a1a8 --- /dev/null +++ b/src/main/java/com/websocket/demo/request/DeleteFriendRequest.java @@ -0,0 +1,10 @@ +package com.websocket.demo.request; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DeleteFriendRequest { + private String friendNickname; +} diff --git a/src/main/java/com/websocket/demo/request/FindChatListRequest.java b/src/main/java/com/websocket/demo/request/FindChatListRequest.java index edcce2a..73c44ad 100644 --- a/src/main/java/com/websocket/demo/request/FindChatListRequest.java +++ b/src/main/java/com/websocket/demo/request/FindChatListRequest.java @@ -9,7 +9,7 @@ public class FindChatListRequest { private Long roomId; @DateTimeFormat - private LocalDateTime from; + private LocalDateTime from = LocalDateTime.now().minusDays(1); @DateTimeFormat - private LocalDateTime to; + private LocalDateTime to = LocalDateTime.now(); } diff --git a/src/main/java/com/websocket/demo/response/FriendInfo.java b/src/main/java/com/websocket/demo/response/FriendInfo.java index 0f45f5c..065b19a 100644 --- a/src/main/java/com/websocket/demo/response/FriendInfo.java +++ b/src/main/java/com/websocket/demo/response/FriendInfo.java @@ -1,8 +1,10 @@ package com.websocket.demo.response; import lombok.AllArgsConstructor; +import lombok.Data; @AllArgsConstructor +@Data public class FriendInfo { private String nickname; } diff --git a/src/main/java/com/websocket/demo/response/RoomInfo.java b/src/main/java/com/websocket/demo/response/RoomInfo.java index 1e75cbe..32b3ee2 100644 --- a/src/main/java/com/websocket/demo/response/RoomInfo.java +++ b/src/main/java/com/websocket/demo/response/RoomInfo.java @@ -8,7 +8,7 @@ import java.util.List; -import static com.fasterxml.jackson.annotation.JsonInclude.*; +import static com.fasterxml.jackson.annotation.JsonInclude.Include; @Data public class RoomInfo { @@ -17,6 +17,7 @@ public class RoomInfo { private final String title; @JsonInclude(Include.NON_NULL) private final List users; + @JsonInclude(Include.NON_NULL) private final List chat; @Builder diff --git a/src/main/java/com/websocket/demo/service/ChatService.java b/src/main/java/com/websocket/demo/service/ChatService.java index 18d4cbc..3f639d6 100644 --- a/src/main/java/com/websocket/demo/service/ChatService.java +++ b/src/main/java/com/websocket/demo/service/ChatService.java @@ -37,8 +37,15 @@ public ChatInfo createChat(CreateChatRequest request) { return ChatInfo.from(chat); } - public DeleteChat delete(DeleteChatRequest request) { - chatRepository.deleteById(request.getId()); + public DeleteChat deleteChat(DeleteChatRequest request, String host) { + Chat chat = chatRepository.findById(request.getId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않은 채팅입니다.")); + if (!chat.getSenderNickname().equals(host)) { + throw new IllegalArgumentException("채팅은 전송자만이 삭제할 수 있습니다."); + } else if (!chat.getRoom().getId().equals(request.getRoomId())) { + throw new IllegalArgumentException("채팅이 속한 채팅방 정보가 일치하지 않습니다."); + } + chatRepository.delete(chat); return new DeleteChat(request.getRoomId(), request.getId()); } @@ -68,7 +75,7 @@ public RoomInfo createRoom(CreateRoomRequest request) { public RoomUserInfo getOutRoom(RoomOutRequest request, String nickname) { roomInfoRepository.deleteByUserNicknameAndRoomId(nickname, request.getId()); - if (!roomInfoRepository.existsByUserNicknameAndRoomId(nickname, request.getId())) { + if (!roomInfoRepository.existsByRoomId(request.getId())) { roomRepository.deleteById(request.getId()); } @@ -84,6 +91,7 @@ public RoomInfo inviteUser(InViteUserRequest request, String host) { .orElseThrow(() -> new IllegalArgumentException("해당 채팅방은 존재하지 않습니다.")); if (!room.containsUser(host)) throw new IllegalArgumentException("해당 유저는 초대 권한이 없습니다."); + if (room.containsUser(request.getNickname())) throw new IllegalArgumentException("이미 초대된 유저입니다."); room.addUser(request.getNickname()); return RoomInfo.fromWithoutChat(room); diff --git a/src/main/java/com/websocket/demo/service/UserService.java b/src/main/java/com/websocket/demo/service/UserService.java index 1de8e79..2346d1c 100644 --- a/src/main/java/com/websocket/demo/service/UserService.java +++ b/src/main/java/com/websocket/demo/service/UserService.java @@ -38,10 +38,14 @@ public boolean create(CreateUserRequest request) { } public boolean addFriend(AddFriendRequest request, String userNickname) { + if(userNickname.equals(request.getNickname())) return false; + User user = userRepository.findByNickname(userNickname); User friend = userRepository.findByNickname(request.getNickname()); + if (friend == null || user == null) return false; if(user.contains(friend)) return false; + user.addFriends(friend); return true; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 19573e6..e0fb1c9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,4 +4,12 @@ spring: h2: console: enabled: true - path: /db \ No newline at end of file + path: /db + + devtools: + livereload: + enabled: true + + jpa: + deferDatasourceInitialization: true + open-in-view: false \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..d61932c --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,13 @@ +insert into USERS (`nickname`, `password`) values('user1', '1234'); +insert into USERS (`nickname`, `password`) values('user2', '1234'); +insert into USERS (`nickname`, `password`) values('user3', '1234'); +insert into ROOM (`id`, `title`) values(1000000, 'room1'); +insert into ROOM (`id`, `title`) values(1000001, 'room2'); +insert into ROOM_USER_DATA (`id`, `room_id`, `user_nickname`, `check_time`, `background_color`) +values(-1, 1000000, 'user1', now(), 'blue'); +insert into ROOM_USER_DATA (`id`, `room_id`, `user_nickname`, `check_time`, `background_color`) +values(-2, 1000000, 'user2', now(), 'blue'); +insert into ROOM_USER_DATA (`id`, `room_id`, `user_nickname`, `check_time`, `background_color`) +values(-3, 1000001, 'user1', now(), 'blue'); +insert into ROOM_USER_DATA (`id`, `room_id`, `user_nickname`, `check_time`, `background_color`) +values(-4, 1000001, 'user3', now(), 'blue'); diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 3cc9828..c97b673 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -2,34 +2,19 @@ const stompClient = new StompJs.Client({ brokerURL: 'ws://localhost:8080/chatting' }); -stompClient.onConnect = (frame) => { - setConnected(true); - console.log('Connected: ' + frame); - stompClient.subscribe('/topic/chat-1', (res) => { - const response = JSON.parse(res.body); - const type = response.type; - const data = response.data; - if(type === "deleteChat"){ - showGreeting(deleteChatHandle(data)); - } else if(type === "createChat"){ - showGreeting(createChatHandle(data)); - } - }); -}; + +function convertTime(time){ + const date = new Date(time); + return `${date.getHours()}:${date.getMinutes()}`; +} function createChatHandle(data){ const sender = data.sender; const message = data.message; const roomId = data.roomId; const id = data.id; - return "NEW room : " + roomId + ", from : " + sender + "=(" + id + ": " + message + ")"; -} - -function deleteChatHandle(data){ - const status = data.status; - const id = data.id; - const roomId = data.roomId; - return "DELETE " + id + " at " + roomId; + const createdAt = data.createdAt; + showGreeting(roomId, id, sender, message, convertTime(createdAt)); } stompClient.onWebSocketError = (error) => { @@ -63,35 +48,198 @@ function disconnect() { console.log("Disconnected"); } -function sendName() { +function sendChat(roomId, sender) { + console.log("sendChat", roomId, sender); + const element = document.getElementById("message"); + const message = element.value; + element.value = ""; stompClient.publish({ destination: "/app/chat/new", body: JSON.stringify({ - 'message': $("#name").val(), - 'sender': "test1", - 'roomId': 1 + 'message': message, + 'sender': sender, + 'roomId': roomId }) }); } -function deleteChat() { +function deleteFriend(friendName){ + fetch("/friend", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + friendNickname: friendName + }), + }) + .then((response) => response.json()) + .then((result) => { + document.getElementById("friend-" + friendName).outerHTML = ""; + console.log("delete friend", result) + }); +} + +function deleteChat(id, roomId, sender) { + console.log(id, roomId, sender, nickname, sender !== nickname, sender != nickname); + if(sender !== nickname) return; + deleteChatElement(id); stompClient.publish({ destination: "/app/chat/delete", body: JSON.stringify({ - 'id': $("#id").val(), - 'roomId': 1 + 'id': id, + 'roomId': roomId + }) + }); +} + +function deleteChatElement(id){ + document.getElementById("chat-" + id).outerHTML = ""; +} + +function createRoom(){ + const element = document.getElementById("create-room-input"); + const roomTitle= element.value; + element.value = ""; + + fetch("/room", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: roomTitle, + users: [ + nickname + ] + }), + }) + .then((response) => response.json()) + .then((result) => { + if(result.status === "success"){ + const data = result.data; + addRoom(data.id, data.title); + } + }); +} + +function addRoom(roomId, title){ + const element = document.getElementById("chat-room-list"); + element.innerHTML += ` + `; +} + +function deleteRoomUser(nickname){ + const element = document.getElementById("room-user-" +nickname); + element.outerHTML = ""; +} +function deleteRoom(roomId){ + const element = document.getElementById("room-item-" + roomId); + element.outerHTML = ""; + stompClient.publish({ + destination: "/app/room/out", + body: JSON.stringify({ 'id': roomId }) + }); + window.location.href = "/"; +} +function inviteRequest(roomId){ + const element = document.getElementById("invite-text"); + const userName = element.value; + element.value = ""; + stompClient.publish({ + destination: "/app/room/in", + body: JSON.stringify({ + 'roomId': roomId, + 'nickname': userName }) }); } -function showGreeting(message) { - $("#greetings").append("" + message + ""); +function inviteRoomHandle(data){ + const element = document.getElementById("chat-room-list"); + const roomId = data.id; + const title = data.title; + element.innerHTML += ` + + `; + console.log(element); + console.log(element.innerHTML); +} + +function inviteUserHandle(data){ + const target = document.getElementById("chat-users"); + target.innerHTML = ""; + + const other = ` + + + + `; + const me = ` + + + + `; + data.users.forEach((value, index) => { + target.innerHTML += ` +
+ ${value == nickname ? me : other} + ${value} +
`; + }); +} + +function showGreeting(roomId, id, user, message, time) { + if(user === nickname){ + $("#room").append( + `
+
+
+
+ ${message} +
+
+ ${time} +
+
+
+
` + ); + } else { + $("#room").append( + `
+
+ ${user} +
+
+
+ ${message} +
+
+ ${time} +
+
+
` + ); + } + prepareScroll(); } $(function () { $("form").on('submit', (e) => e.preventDefault()); - $( "#connect" ).click(() => connect()); - $( "#disconnect" ).click(() => disconnect()); - $( "#send" ).click(() => sendName()); - $( "#deleteId" ).click(() => deleteChat()); }); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index 18dd4db..0000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - Hello WebSocket - - - - - - - - - -
-
-
-
-
- - - -
-
-
-
-
-
- -
-
- -
-
-
- - -
- -
-
-
-
-
- - - - - - - - -
Greetings
-
-
-
- - diff --git a/src/main/resources/static/main.css b/src/main/resources/static/main.css deleted file mode 100644 index 8643b76..0000000 --- a/src/main/resources/static/main.css +++ /dev/null @@ -1,14 +0,0 @@ -body { - background-color: #f5f5f5; -} - -#main-content { - max-width: 940px; - padding: 2em 3em; - margin: 0 auto 20px; - background-color: #fff; - border: 1px solid #e5e5e5; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} \ No newline at end of file diff --git a/src/main/resources/static/scroll.js b/src/main/resources/static/scroll.js new file mode 100644 index 0000000..adfcd61 --- /dev/null +++ b/src/main/resources/static/scroll.js @@ -0,0 +1,11 @@ +// 준비 함수, 약간의 시간을 두어 scroll 함수를 호출하기 +function prepareScroll() { + window.setTimeout(scrollUl, 50); +} + +// scroll 함수 +function scrollUl() { + // 채팅창 form 안의 ul 요소, (ul 요소 안에 채팅 내용들이 li 요소로 입력된다.) + const chatUl = document.getElementById("message_output_layout"); + chatUl.scrollTop = chatUl.scrollHeight; // 스크롤의 위치를 최하단으로 +} \ No newline at end of file diff --git a/src/main/resources/templates/addFriend.html b/src/main/resources/templates/addFriend.html index a3be3e0..bba2d68 100644 --- a/src/main/resources/templates/addFriend.html +++ b/src/main/resources/templates/addFriend.html @@ -1,16 +1,98 @@ - + + + + + + + + + + - 친구 추가 + 친구 추가 -
-

친구 추가

+ +
+
+
+
+
+ + + +

+ 친구 추가 +

+ +
+
+
+ +
+ +
+
+
+
-
-

닉네임

-

-
\ No newline at end of file diff --git a/src/main/resources/templates/createUser.html b/src/main/resources/templates/createUser.html index 5d096b3..a2fa0c6 100644 --- a/src/main/resources/templates/createUser.html +++ b/src/main/resources/templates/createUser.html @@ -1,17 +1,99 @@ - + + + 회원가입 페이지 -
-

회원가입 페이지

+ +
+
+
+
+
+ + + +

+ 환영합니다!! +

+ +
+
+
+ + +
+
+ + +
+ +
+
+
+
-
-

닉네임

-

비밀번호

-

-
- \ No newline at end of file + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..e15cb38 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,429 @@ + + + + welcome!! + + + + + + + + + + + + + + +
+ + + +
+ + +
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+ +

+ roomTitle

+
+
+ +
+
+
+
+
+
+ sender +
+
+
+ message +
+
+ time +
+
+
+
+
+
+ message +
+
+ time +
+
+
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index d04e03a..4210d72 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -1,17 +1,105 @@ - + + + 로그인 페이지 -
-

로그인

+ +
+
+
+
+
+ + + + +

+ 로그인 +

+
+
+
+ + +
+
+ + +
+ +

+ + 아직 회원이 아니라면... + +

+
+
+
+
-
-

닉네임

-

비밀번호

-

-
\ No newline at end of file diff --git a/src/main/resources/templates/room.html b/src/main/resources/templates/room.html new file mode 100644 index 0000000..07d0610 --- /dev/null +++ b/src/main/resources/templates/room.html @@ -0,0 +1,57 @@ + + + + + + + + + + + +
+
+
+
+
+ + +
+
+
+
+

welcome to room1

+
+
+
+
+
+ user1 +
+
+ message +
+
+
+
+
+
+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/test/java/com/websocket/demo/api/ChatApiTest.java b/src/test/java/com/websocket/demo/api/ApiTest.java similarity index 88% rename from src/test/java/com/websocket/demo/api/ChatApiTest.java rename to src/test/java/com/websocket/demo/api/ApiTest.java index 84ef7c9..86341be 100644 --- a/src/test/java/com/websocket/demo/api/ChatApiTest.java +++ b/src/test/java/com/websocket/demo/api/ApiTest.java @@ -1,20 +1,19 @@ package com.websocket.demo.api; -import com.fasterxml.jackson.core.JsonProcessingException; import com.websocket.demo.controller.RestDocs; import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; import com.websocket.demo.request.CreateRoomRequest; +import com.websocket.demo.request.DeleteFriendRequest; import com.websocket.demo.request.LoginRequest; -import com.websocket.demo.request.RoomOutRequest; import com.websocket.demo.request.UpdateRoomConfigRequest; import com.websocket.demo.response.ChatInfo; import com.websocket.demo.response.RoomInfo; import com.websocket.demo.response.RoomUserInfo; import com.websocket.demo.service.ChatService; +import com.websocket.demo.service.UserService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.BDDMockito; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -23,7 +22,6 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.refEq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.spy; import static org.mockito.Mockito.doNothing; @@ -37,10 +35,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class ChatApiTest extends RestDocs { +class ApiTest extends RestDocs { @MockBean ChatService chatService; + @MockBean + UserService userService; @DisplayName("체팅방의 체팅 목록 조회 API") @Test @@ -237,6 +237,41 @@ public void changeBackgroundColor() throws Exception { ); } + @DisplayName("친구 관계 끊기 API") + @Test + public void deleteFriend() throws Exception { + //given + var info = new LoginRequest(); + info.setNickname("mark"); + + var request = new DeleteFriendRequest(); + request.setFriendNickname("nick"); + + doNothing().when(userService).removeFriendByNickname(any(), any()); + //when + mockMvc.perform(delete("/friend") + .sessionAttr("user", info) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.status").value("success") + ).andDo( + document("delete-friend", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("friendNickname").description("친구 닉네임") + ), + responseFields( + fieldWithPath("status").description("요청 처리 결과") + ) + ) + ); + //then + } + private Chat createChat(long id, String nickname, String message, Room room, LocalDateTime createdAt) { Chat chat = spy(Chat.builder() .room(room) diff --git a/src/test/java/com/websocket/demo/controller/UserControllerTest.java b/src/test/java/com/websocket/demo/controller/UserControllerTest.java index b4f7fa6..3e6541c 100644 --- a/src/test/java/com/websocket/demo/controller/UserControllerTest.java +++ b/src/test/java/com/websocket/demo/controller/UserControllerTest.java @@ -72,18 +72,14 @@ public void createUser() throws Exception { mockMvc.perform(post("/user/create") .param("nickname", "신기방기") .param("password", "1234")) + .andDo(print()) .andExpectAll( - status().is3xxRedirection(), - header().string("Location", "/") + status().isOk() ) - .andDo(print()) .andDo(document("create-user-success", formParameters( parameterWithName("nickname").description("닉네임"), parameterWithName("password").description("비밀번호") - ), - responseHeaders( - headerWithName("Location").description("redirect path") ) )); } diff --git a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java index 8c795bd..9fc3057 100644 --- a/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java +++ b/src/test/java/com/websocket/demo/repository/ChatRepositoryTest.java @@ -3,6 +3,7 @@ import com.websocket.demo.SpringTest; import com.websocket.demo.domain.Chat; import com.websocket.demo.domain.Room; +import jakarta.transaction.Transactional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/com/websocket/demo/service/ChatServiceTest.java b/src/test/java/com/websocket/demo/service/ChatServiceTest.java index f7403dd..eaa300a 100644 --- a/src/test/java/com/websocket/demo/service/ChatServiceTest.java +++ b/src/test/java/com/websocket/demo/service/ChatServiceTest.java @@ -75,14 +75,17 @@ public void createChatFail() { @Test public void deleteChatSuccess() { //given + var room = saveRoom("room test", "nick", "jon"); + var chat = saveChat(room, "nick", "hello"); + var request = new DeleteChatRequest(); - request.setId(100L); - request.setRoomId(10L); + request.setId(chat.getId()); + request.setRoomId(room.getId()); //when - DeleteChat response = chatService.delete(request); + DeleteChat response = chatService.deleteChat(request, "nick"); // then assertThat(response).extracting("id", "roomId") - .containsExactly(100L, 10L); + .containsExactly(chat.getId(), room.getId()); } @DisplayName("체팅 삭제가 실패한다면 예외가 발생한다") @@ -91,10 +94,41 @@ public void deleteChatFail() { //given var request = new DeleteChatRequest(); //when //then - assertThatThrownBy(() -> chatService.delete(request)) + assertThatThrownBy(() -> chatService.deleteChat(request, "")) .isInstanceOf(RuntimeException.class); } + @DisplayName("체팅 삭제 요청시 자신이 작성한 채팅이 아니라면 예외가 발생한다") + @Test + public void deleteChatFailWhenNotAllow() { + //given + var host = "jon"; + var room = saveRoom("room test", "nick", "jon"); + var chat = saveChat(room, "nick", "hello"); + + var request = new DeleteChatRequest(); + request.setId(chat.getId()); + request.setRoomId(room.getId()); + //when //then + assertThatThrownBy(() -> chatService.deleteChat(request, host)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("체팅 삭제 요청시 채팅방 id가 다르면 예외가 발생한다") + @Test + public void deleteChatFailWhenNotMatchRoomId() { + //given + var host = "nick"; + var room = saveRoom("room test", "nick", "jon"); + var chat = saveChat(room, "nick", "hello"); + + var request = new DeleteChatRequest(); + request.setId(chat.getId()); + request.setRoomId(room.getId() + 1); + //when //then + assertThatThrownBy(() -> chatService.deleteChat(request, host)) + .isInstanceOf(IllegalArgumentException.class); + } @DisplayName("조건에 맞는 채팅 리스트를 찾는다.") @Test public void findChatList() { @@ -172,6 +206,7 @@ public void getOutRoom() { //then assertThat(roomInfoRepository.findAll()).extracting("userNickname", "id") .doesNotContain(tuple("nick", id)); + assertThat(roomRepository.findById(id)).isNotEmpty(); } @DisplayName("채팅방을 나가고 아무도 없는 채팅방이라면 채팅방을 삭제한다.") @@ -244,6 +279,21 @@ public void inviteUser() { assertThat(savedRoom.containsUser("kim")).isTrue(); } + @Transactional + @DisplayName("채팅방 초대시 이미 있는 유저라면 예외가 발생한다.") + @Test + public void inviteUserWhenDuplicate() { + //given + Room room = saveRoom("room1", "kim"); + var request = new InViteUserRequest(); + request.setRoomId(room.getId()); + request.setNickname("kun"); + //when + RoomInfo result = chatService.inviteUser(request, "kim"); + //then + assertThatThrownBy(() -> chatService.inviteUser(request, "kim")); + } + @ParameterizedTest @DisplayName("채팅방 설정을 변경한다.") @ValueSource(strings = {"blue", "white", "red", "green"}) diff --git a/src/test/java/com/websocket/demo/service/UserServiceTest.java b/src/test/java/com/websocket/demo/service/UserServiceTest.java index fb8f8e4..aa720dc 100644 --- a/src/test/java/com/websocket/demo/service/UserServiceTest.java +++ b/src/test/java/com/websocket/demo/service/UserServiceTest.java @@ -102,6 +102,19 @@ void init() { saveUser("friend1", "1234"); } + @DisplayName("스스로를 친구 추가할 때") + @Test + public void selfAdd() { + //given + var host = "hello"; + var request = new AddFriendRequest(); + request.setNickname(host); + //when + boolean result = userService.addFriend(request, host); + //then + assertThat(result).isFalse(); + } + @Transactional @DisplayName("올바른 친구 닉네임이고 로그인이 되었을 때") @Test From e0dbe916ee3e4a3585fb6480e83dfe0ae5df4ed0 Mon Sep 17 00:00:00 2001 From: yudonggeun Date: Wed, 20 Sep 2023 15:45:21 +0900 Subject: [PATCH 14/14] =?UTF-8?q?readme=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- file/addFriend.png | Bin 0 -> 5618 bytes file/chat_info.png | Bin 0 -> 78344 bytes file/chat_room.png | Bin 0 -> 22891 bytes file/chat_room_menu.png | Bin 0 -> 30902 bytes file/chating_room.png | Bin 0 -> 48835 bytes file/erd.png | Bin 0 -> 43142 bytes file/login.png | Bin 0 -> 9862 bytes file/main.png | Bin 0 -> 21099 bytes file/welcome.png | Bin 0 -> 9888 bytes readme.md | 70 ++++++++++++++++++ .../websocket/demo/service/UserService.java | 1 - src/main/resources/templates/index.html | 18 +++-- .../demo/controller/UserControllerTest.java | 1 - .../demo/repository/ChatRepositoryTest.java | 1 - .../demo/service/UserServiceTest.java | 3 +- 15 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 file/addFriend.png create mode 100644 file/chat_info.png create mode 100644 file/chat_room.png create mode 100644 file/chat_room_menu.png create mode 100644 file/chating_room.png create mode 100644 file/erd.png create mode 100644 file/login.png create mode 100644 file/main.png create mode 100644 file/welcome.png diff --git a/file/addFriend.png b/file/addFriend.png new file mode 100644 index 0000000000000000000000000000000000000000..20d83e037b83af43a0f508ebe143cc1c32fe162e GIT binary patch literal 5618 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 literal 0 HcmV?d00001 diff --git a/file/chat_info.png b/file/chat_info.png new file mode 100644 index 0000000000000000000000000000000000000000..7325ea3e7ab7e37aad969d0abf9f518082648797 GIT binary patch literal 78344 zcmZs?1yEIe+dYgDf|Sx6QbACVE@|-4Dbn3=XaVU`lmkeEbRN39Q@Y{M-QC^wZM^U2 zec$IlA2ZA_XXdc?@7hJ_kKVKtRBhlo0uhfPfT$fPhGV`V{!&zBG&u z0pS~hq=>MRv(9efGc`rkht4~v#yN*E<$`fcYf)RuYGh$Nqvu_rn57pz26^vQ6CEne z=iI&{wtxR1#91odLW3mn?NU&Z@~7JqFvi=X3lD+8n}i$QjU=s2k~sOn2HLxMVaks` zRa8WaY=CQSa**@6R!;SG%y+l8&keT5@2>3}NMpPX1^fLjSpBwlFTC`m`Q`UlVSXv_ zw+Jtw-$Eq>dmKOOQ6ro_Jwo%J3{iaH_hrkML5PwJ83S|EOp4xuNvFnT{WdaJgQk)2 z65|ONu?>p|1dsm)6BYsf5Xt@qd%`c}s*=;~)69c_a+X*LHv4lpleAy{MqlUj1-rrlB$)NiyU!`dmq}zmMN(>-ucQ2_0^F zIIf@Vac0ujo>w)@YVQ&mSapR~q?pto@$tt(x-rbffSj-K%bJPY6Z|us8rzNeR$2%S zHpOeAxYh4SmJ{CBT)zpc-_GwH7L{>~<%g!nU)5}(sioKFQA$no$HO$lR){Aw_# zOkbgn!D=^bOPNg#f-%g_%YF&v0&AT|||R-q<*uJr`OTLSSb1>v{tI zfr`(}{_PnnJ0{s(+i#gsvE-VtTz;=ukqvhw8$O5i<;EZ4pA08-pUK@jJ>mFt*S>?N zioNh_o5u)3KL1n76_F0b3RBQ!{b}OOZfsgWXKyuyl)&#K@LYfDmt~wtWnjiL zBwCWzH~4F+=0RlRSe^PVx7gO-b5?w%u}xsm(p+%g?(<3Ol#S7=r6>NKSr9@q0)bD`V(P>~L*)rXy$|li^2vW^)lW zvYq!_{btMswNL&3#?Vz3Y@~}Cc}9{rD_LJl@y0S^ z7)4V&IS8sEmg#tVghEp(mU%AGr}ECC_68~SSC(#9U(NFiLkh$H_coxr=xAdR8%56A z;&KTB&Z|gv#Z6eG0_O)_^~=D=iL>>gU%$hi%mH`)Pj;2Fv2*Z~JY&+Ci>~)LV#OEd zdi|Nzil6P?xj!f~Q)Pe;d~&$PSGz8X8q1-&)Z9w@Z^55H#bn)xl7-t<6o|NM6-{m< zlztVYv5z&>+tyM&7sh+@*51?`S1u(((m#-4`9M-QT|PZ!|P#E+bP*)X|j zu(bl0=)V#0XUsFy?>#`zTiz>;ZDlkz*tt|n*NzborA-VjxDfd#!cb?@hn~J+$BFqL z!kVpn3A4w)KDcEz_0!KUFXEIku-a|t7%KK=4eE~6@cIBPX71^K&UlE4S;1gAyUwUq zSl2z=)u-RjGdI?w;rW}HQ2xOsKqc1<1d8W|Y+6TY=I(9*iG%Xe@Hz&@!Np1fQqvMN z?=JX+-|83t)MuQzv?3$R!4h(Y%vqMHD`$9!p5&&+jm~EzoUM~iRMAvf>A3J z*2d!5?+I?RKa1q8Rw(?qI0fNU-Y&B?hDWN5r-GB(pM;C3JIw7p8)VX|8{FtA@HJL; zxD4}_D24fmTwWrF{@|&$qafVrKKUw9?YiT?)cAAp&x?Egbc$CoE&S9qYBI5PElkB$ z#a=?$SzPU41l?mto#>9-vG@j!Y3HZl77xEy->a|PUb#(54@O-aca9%b$I}l)ajk2+ z*gS=zp#JbFh+h{|OoG{x3zpexzAa++s7x3aQ?yxD(HZJaEO)P%9*qsaQ!byaN0gY| z1CQtKxLqTsRdTU>h1DDEre*z`Z_-eeM6!+StVT!e^u=&>c@hu};aeJVLXFiNMX-g* z8=aXt$MN@=^fxub=xEQZ;k?D=+YRU@)1+?S5B?Bq%we^nUGEFKA=}+rn^O^i5D+|& zG~TR;^+nKEQ5@Bf-yt<|prS}}vOhPzt@bBLtkU0sDa;3FQl(G4v^^t`C@iC8I>m%s zg&`t(Z^%ZnU96DekOhfL_FrHwVh4X^R=PUc5MH?JGA-@{-5^*O{Ypsq?)I!`pmk!( zZnf(w7|*gHQZG>%#F&nS6tZg0Z`WcuSczM;lbx?sT}?)kFVS_U6-X!_Qy`7iF#sO{ z(zgL^w-Wm$w?`{{vTr(u3UA6xF;cbfW=*q~^gH7aM*r8{Vy4F1QMAoiGvbqbWUEaWQVzncQ4KrZO z);*RaA)$s@Hqo4iLHWE;6CT5ZG_CKi7=Z4MAmkl}07l1{-(OxeuXhQEY~W&HPA$me z!U!sBfqIHEAfddIjc=T1^qUyV0n({@da5sx-S$>w>&Sj$#tqr2JX}nN*&sK=>1|;! zS9vJ%=d^Sa#uyg2R}}b)g3RoTY)>A?dz&RBzjiw{x3c0LL0V>cd1dU3wRJudeV?4X z{9s=iYYap9NidZ_{&Nil6W8mR-H)yw(uw`n@3V%sWBP_?tF~OqQ8aRc{a5zVm($DA zVav?yJH*3oHuW+VvOgoAFThQLNiJ_<&~R5HlL>8;WL7e2!eR*;#I2i4714~{vwqF# zSNng(NxZL2k5e?_nxD+9n}@G8xiLxS?Oe6Sfy~Ro4V=d~_fmo96-ZzHAlNg}-{ zYbFp4H!}aJhO6KocxLc#6_pXi5x981>InHor&c2jlT^U^yLWhfrm;}nM%`=e0xn5e zb)$9i7N*>;e(jX>025{hv{fZ1ornylqTe&lde0p_#5Yd zN%t-E5q8Qk5o_4q4wXc#3o>O}Sz8z8bvdw()y67>>}GAAkK0D53Jw&Qo10tbGA-ED zKyuD>2;xm44^^(cOFgcmt-4Ozf8aRxRBxBl@U-9=Fm>5@nOUcOa|=F)C@1%5L;ErE zUVO!kE*G(cJj2SpyDsz&IMxTo>uV2U0q~L)vW=7Y5z%yvek(zdh+y%)Pg}6(=ObVQ zBYe}zRK;YVZs{JH@*x^`CEBlVZ?8Fn4@N`Gj7_aqmSmTD#nOwgn_GtG`#KVJPiYn3 zlltF3dBw>Y9Guj=w}CyP#b;BSB;WhWht+!baxG3JzJCvCY8hwqOaQ#I%-DI~boYuw z+c7uwyWoKn4$jivQcjuUTHCwlQ965;FTA1+^&~`uI-9yBw4$9ctVYu#p_!Xmj%VmC z=jWt)5TVBw{*t@F7=qUMK+1bIdy+6+3(Yj)^d6a$SSX~=s@}s&ROpmGidP&FvoPZ1 zw8yl%^{K+V&2erf(a6f_Wz&A^#hF2>DW1b*7emFLtxdD%xAgYKI2_KdtfZk#_Csgx z+ss~CEi*@%UsVAWEq=SrLOoMX>{MTrOPe9&OEeV%Lb#L-NJ9_K>BAkNqF!ETi;T>W z{J|ym4f2)0?^*{7Rp$fe*U?3NLsJHYtS?a>ugPS%g30;y4?_h}Fz@V7SMkpq6RgOt&_hba%A_ zwj}VRLz*x)?nmvd{0uH>-%|f?9E~6cYRa_TG=@;@2<1;KPRF&^-YY`=fu9qNai2+d zzwM$w42>7u+weS=&y9C>;+}u|$1_%~6M$M@_L&=h6Si9ohNI!u!)#lzI8}HPp&YQ_ zeLG7T0hh(YfbjEnCkNvk{a4X>(_^_8J#vXP9SCmy`o6h2v3d}oWhMWFA^X0SjS3(7 za*Qo(SXoznh4fPd`LaezKBjW0XF`ygm6B4zaLb)oIi4~Jh8eAqwX(k8iF@_un_hop zKsx@Z|3|HDw*x!UhY`K+d&DmldD+WTLj4#t6SUCOxK{Kn^jAKbzs&mcrvT+Te!OsE z3q)prp|XJ&0&CU|{?>;0m`eP55E?7!xl#0p5&#J}A0Q@UY{NMFNZe-FWFKrV%IqA$ z85zTY9B%v3k?5J;Q+Cm5aA=hK?M5?I6r<+6z_AzBL)l6lX-$sg2X;e$b2IHagAE{O z?KhVT&e5vWf3TH9rNt}=T=qHx`J6s{8CIL{cBCR!wd67^jKM{Wo^4^zhize8ZSUAL zT19^!s9WiI?B}6c0%&0LDeH2BA!=fLdtQnKf)T&3!x0o5Ej+4| zBIHNPLZUJ3C2(gtxz1siL~6*z0+!v7Uj%{DcJ~q|xq@ajuP4up&wI1-LtIDutg9Dc zth!Y7nPj;{8W}34Rv3TN%G7#|eccdbmisfR zj&uykam(+toF#$BV&pd$2WKVBGuhQj zaTbu83#>qj8oim4-V*Hn#Mw38b@?s+BV&j=cuBFsXIM;U#=4_zp3kdKul;mv-&f7@ zypy$D?)9y9FezB`!N%@pxvr;ghmAJC5{MS?1QF0OxiA^tevq*pN~npmu5YJ*-Yjl@ z3Q2-hyubk_2eZlS)1q&vO!jLmY!l&^nj2LuEbPQ*`8WoP#0l&Sg=iH1nOn62-9TP@1vw+`i=>81KS>77!2Vl_d6OHbxB7_*WV9N|niOv94xC{@ujE!o~5r zx_8oGv$gOy(s+@wjQqVzd-%t}WeZ}dRsP2~$fIRF47Zk|h^BWz?Opa0T&^0w<^)(S znqOAn>$k_4A0GjTbeq@m{$eaeL7N0fBKI@FUG)Wzixnn0THT6;KZwNB`8Hp`O?TH5 zSeSeCv+#$zgBe{EZ`czAgs(qzILhIu7P1-5RGAh;Q|fAy?=_iiRu{ua!6(>Sxv2#< z<~I6!*;UqSs9VO3-(Ly~ueL{Y4r*mKObrjU^a!gePQasxezLLi-Z_Ac$Ky45)yqrU z{bws%Xsp3IFZMxk^#u!#rzE&W{Vl;0a84d%Jux0)hSma@!}9?+4=|4}Nf90+WJ?0# zvgE8ot2%j=H9tE$I$9b+MI)OKT&aCl7kyotMMkq^%x!HPluRSGRc1O^oRz{CFv!qa zn2&EttM6-j%ohzEqHty`*eZOR&8+{DaL-%XY5C1GoH{HN)lMjexk?uW#aH1e@8n8JmN0Z( z4%MDY?1>Q5;RkX67#sX04T&{Sf&h?}9xiD?$5^00%E8X8E~Ap>ENnkMI*t$Dyqwtc z4gh$V>LubhdY0!1uHQcZ_mcfD3xyC0i9nDQmtOVpAFyxkyQYX z{sah41PSJ;(d*i4l=@HpiQ6C}i0mc$<_l(aq21GRSk2|nw_Qw0tl~4x*fYrP(Em%F zena|!QP3!S8}&PGny zeC9dsQ=;NxIFmn=< zlt%Z$+{gw(oApnR?Bjo*xrJzYi+^7JnEvRDGyC0=*IdDRC2rBavNliaR#zGH-0<;0 zz0Zi_CMtt!r&qGxkJ!)e>owZKlgnu2V!3QnPXmLH5a%j(EPrSY0yM40-->KZw3+eBrxBv{S?M&w+#h9^U_ut_Y!ehtTm);sIYrDR;SD?wx;z+X^uzI&^uZ zRYY7&?Wi`$I=r?qWmb0(_v>i%x^r6_Wt?+hbcsCkSEWifsnH4S&9>4TXCuMIVgwE* zz)@=kQD98+EFC_fk+5KxU@tkw8vCrqp<^DLYnFn}^3m%@hse9wBYN2cR0SHbT8&xX zk!s74&uq5TdRb2e!2g}A063Rip2A#J{7Pd!VcZ~>GG2@oCNn^zpfEAh>6?Ec$`*^6 z(JmV3O3=VM*lWijwg{r8kI_=NaP>BbV^1HIr z(rzrb2S`dv-kk5{<>oGjaC_c5YiW@dd7N#JO%&;#)PYDy6crVxEIG}iqoX6bjg*A< zrl+UbOb1fTLdbZdl9Rp8cc*^;{;gf-I5s+ZIOua4`M(wC49@HFQ@17v(^b^fRdv~0 zTv`q1$%n0pFlmBxv}XQfzboeYy+)+5f9La@S87}1+zsjRxFT%qTm~_O%?t5D$1f@7 zMSk(x#?yzDMG8{pd^LfHii!#d2>AH;w70jPot+VZ(b3QpK79&}jMT3F^2(gDy#)YW z4i0K+YKn>@b?4Bi_;~5pVer*AZ{A>HV(LLWfHHgm146PV`u#$tliHKRF_+lEte%xo0}LZiBZiU zwDL(9XlS;!woAksT3WOBcQ^5Iad2HF8=HfK1X5wJ+tso0moJAuo?&B=^GRflxL82m z6YP*VVamec!4!gx4m&EQrl!p0jWM3Gvi%5$Iso1m@?HYP!575to&a4HTVNx>4%FV3Q zgrYccKV8nKbK=JF8vuq36J9tmQC#}#blB2?o%{W5O=KkgccHiM-nsbJ^_Da?&X0`P zQ*q7D&!eHC@zMXNBYpGkf09gwpr9o6S1=)6iQ;Fm!4!@arWBw*Ae4+~gQ;*559QwI z+;+z|!>J<&FbYabhdVmNBH|Yp7wtC&jxd9o^|Tt@uTHl{9336+@9(p}pJLPyEWphz zEP{f9PL7Y`Y9!_4#7aA7IMU0@@m$Dkri+VQ_lEbUGkA7{M`NjO7| z9IKcbb-k5#LU|mPZ5EJ1v4F9liKU%H;lm-+HhkbdVg@iUaho~HJ32aUZ*MKXr?g5( zGag|U0H+)sWoKk$^o`%#gske_ID9OAO4JPRw;iae@Gc z``atTS>W8URo|f4ME`q*KN~^>L9yZ)8<9!@(OTwKGzBjo?6iSJ*Dq0rlYP#i%yO3F zQ|pXl4Qy-LxF_HbT!8*3JG-MrF*ciRr;Pmp3s4|q2}`m0qzHBqh36v z=+xA*vs#2-!C4u%Un?RZ^@ZCq^B@qQTcQ2e{N~(h@0+RGPpFPchEB(Fhuz4F?Sk7H zQ!9fcrTTfC+0RA&i{n{5{|baxpTPE?Vk*8G3+=pmU=&ZS3-|t|MX4~-_esa|esxQ+ z^P$lM8300$yukk?C*}#<(T0o5S9IGn!fba#wUPn7+VO5Feh2$x9zH35pu^){#4NDn z-Alcy4zWq(hR8*%WMM@8ZP|2&mVU8U}J^ z)1^?rh}Vo1<`6tG^!}PsX%hx*)`cE9Ohbqf6aJOx@N;RCBE>{r4WBZoiD@yU@Ya&; znFDkAAnt#`0(T9Jf}`mYD-}(7GVWS=?ZF*|q(A&YK3{>1ls{R5cG%LTMhPnLhNj|5 zjAKz(;URXBo;|0AF*1x|Esk|h&$c=m7MFd8NiqT<3wz{L{^GW)av~6KMQCh?%Wa@T zLNN<_T#`R0zbchs}yMXFj{U6XFPxY+|JGpo#4GtG#wQco7?#= zF`J1@JSV31ZMtLxjZ6%)Ijc1*hd?=qoS^Cg`W^cHvj(c zCd0nU#ZnziUwC5A3({`0y5M^hZMO`jB5CM||x6Xv44uzb7!E(LJ2?`deUwT(-Yg%Jtl31Fti3trg zbz57TR`r*q&7m}2r+o}m)IV`i*{@!p8nF_~65-+Dfk3q$H|K|khuPV0Pw9XlOjX$| zd`EnC51j20I|+K;E_gkNE`MF`i#^#G)U9_u>WQSE07i|=$$BEM6MSv1$xD!vlarBg ze`_S`=;(+^r{4K&Tlw?n&r(vr69iqqD=jMvb0+{UhL5k1Gg?|&`s>%P?QL_OL>*Pt zP9s(l5|SxP$FE=6S;eS5fl%iC_<gHSB8U+LLk z+AEA7BG&zJ9C@lmV$XfIfXx6QC3>q3?!6}?t4g2K-A~K~_DoDY7SkMjp(>q-AMbw| zvO?D76vTD z6%~V6h3K`~pEP^*I5;;vP@tSP^L**1liOK@oOS-{ot6e=C1m`ez}RG9U;r}vaeYFd zPLk13BnXNWHU34RVTe*NvtU2x5A%5;(VVRh<5cYLNM3E~7yR%jS>phcXGtvZkou=9 zj(5A1A4$oK>eEw(M+Bd>$0+XTaYYd15{ESrtMziT2ToT;;Hz!ZqH?2VXK()ui}daB zHgS9+E~8+}wF<9=LDQ>2kg=2eEM0phh1Q(BzN%SOmT~m1&QZgBthJ83oZP^-Cy3_e z8v9Obk6S%;JSp7|6}h6Eg1mw}Q@=j>4wUj+)*ui8wZg~dm_1)sbjntxZ@s;eogyWVjJ#y`*#_QAtc00PP zybpE?O_*80-t;y%H`f=Nf*>E5g5QuLr1SD%Y%%#ANokGWMd^#WQysy>JZ$WOHbrB- z(Yc@+n^CQd+#R9k=4|W~gwba^4&%>L^0Pn4n4C?Yae7u`@BA?Z_V|D7OmYFR#E?V& zWVPYZ+u*sro@x*3+ugj`Y9LjtjfO2W?C1@en~vTThzANZ1Xfy3v0`ImTb_C7M<Q#;n;bgT4=DUw0xL=9ZDOtxQ7py9_3bn71v8qs zvBUi>A%%s7B_$=^fVBr9RH+n_|BH@iKBr9%F+y1=@8!K?XE0_EUb(PYfxiX98|GO- zNL`|TSQLl94di?C5_ONtYeK4uSd+r$dd@H$lwOF?3*azy?s(w5!os6d%53DEm%f-|?XgO`jxR&*!|P&RKfzA19WK%p zbi5ezq2S~VV%-x@%i9AG@jP70iD4`2RUj|NdLjquMeC~iXTe*T19buUOb~kA(XS<@ z&z_PAv7(&iOij0?m6daXkiUVj%c*cU!*qsiqtWG>vkAiC)lS^5b-Y{ZE{9GtS)q4x zlKL@&%O-JU`3)tai)>k3nEmY|;-a0sJ~QJAx@E}RRTcREyS2@v6omn|w-BLN9wh>^M8q&y&lcM{9hr5N@ibEcP# zpAKO)de;09&W7v3#fr0sF!~(j$Ot`+fpIO`yYXJ${ z!8hHBaf#tZuElAsP#K&lHjWR_t&X^;bO11SxJ=dKwnWEO^i62W^>%?RTj9seeA)%H z+lb%wwr?!rc3C-`>(yp^uZT*XxIRXIEA66O-;ceQZq880)3z;Id0e1Ph1}V%16ctG zFhDY4Q}ByoGMCRwUcAr9T_i53L}b|vxOxH33p|VCRqJ?Ap5hnZYG!5R`lY$QjL*)_ z8LvEX-KO)*Ws%u6a0O8=FWBdVowIIpf5?ks-SK^E{_*ck>5u@P4|z)&53|Xc&h00a zE0!$f3xp6m1GihNngxaYmU#%@apn*}VS2=Z&SqXslP) zW(@hq8>(FdySj3I@sxM}seq8%}LEKLTDcDe;+V*O@qYEu_5DU-IY8KmI*+Pt1!ZFhQ(!(N{IgKnW} z&`I|}x2fpf_nl{vUGeiBjn_zM`s)ijz7Maf82qPH@Xi=JOAI?{XzkAhmH^%tfjaU6 zq+#4`|IAKLI~sa^de{-VNjjR%BwaE*=YBuGa^ic3G23dft?u3GIs%A{gW{~^1f==0 z(J@Fz*uY*(zJwW+jNQ~*lAFt8G=mi=?BEf-GLI;Z{yaco1#sF$7tZ2ofx*dE$$ZkN zm@_`VL!5XXPlYEP7U*XJISb+6kez#RULCG0Y_9ZzjNr?*mAxCdHET!5b5hwW@BY1v z(u1`!uOsf6fbo<~Q{P#w?vOQ|0;a5n2G6Tk!o`n_Q@GSKfPuqzb8N~8iaZzx$Chgt zK^*4`qp^RgX;{zOuD|**r8bydZOhj(J=>C4x}q#BD|{?V<)c5Md>Y(_Kh_jfhjW)n zF58mtaHNzrUOw{!Fs@!!yHkVFXCbS`mU}fO{+UU=oK~V!n3|P!-Y^doKRt+VNMc~) zgf*Mhs|ejszb|Q!UtUnpPbD56JK!9IX6XZ*R8t9ES7bO1rQtlK|%w;*8#$rYW^ zAf?T>ip!U(1$jdWk`K3g&SqpxX0TB8APu+*??qW($H9(TOtO;GL_`%!)<-ISIL!58 z$IrFpTO=rufJ%q(446;vo2*TMgRInYlV&zxiF~99r#fDQK>!4f=iO)WGyP295?Iz~(Qo+tbi{Qy?I5a4BMP>`5CEkrRmJ3Jgqu zj_PP@LsfZXz&ND= zi@S`H_biom08Up*KD8CsVXCV4*o!YWAKBvWEHj{W)Kx|e&Cd2^?>LuWVd{Nt4EB{1 z$ui7)VJrSv?oYrwuPw&&xxMb|_5LDS{Crr6nhjIfC>d7N_;`kvKXXXE_Aj<_T3=2L z3eV|Js`k7+5)l>?!@b*4nmW&1**5WP~+ zmv+uiOl5O&*DV0k;;segEC1N)U1vTEB%U_H&R{VyF}rhhs{ek?6grNp1n|lfkvc5p zd)Ka&a>c#^z*n!nxi>}utgvW)K>-yN6*B#!UX-)?*@D<%QkKxo?^o`$j0W31i07|! zo!<%)%-C33dd#+N&U>j}_iga6JaXmEu%^fiOr>L$ljj_55nDUE=a`uI{{j{mT|lP}U4FhU( zt=LozN_F-0pr+xPj9)p+csLsxJ6uVtvb*jq|I>eTGIt#)v~9O5jEatqPVnLOWV*tl zthBV4%Jt2zJ&&$~HLxPwwHRvI5XZ&GYYGW{`|?_#I|a5=l6_$MZMy&?k?l=3YN%fpPhw6ty*3GzpN04J_XDK&J2il?{MIOO zT9&VSgN=$h<9pBoaIRuFk&+g>PeS7xOv$hT7LwBOQN7AU;dx$5t|CXyMnfM{gJM~J zSZa5y#Uk6=(d(3xUs{va6j@P_WRC}|Bd;DwfD=zJ{oD7oxF-mjd({5eSdsAf>i+f9 znP=qd9EaPdsf0Q@jaZCUq}30V>Wd=HzcU-9x?DqqmDgxyU4I7^R1S9Hvw1p$&RZ81 z_EJ4VuhB^2qM`_{9nc>8U&`PSYn@5>=&0x2b$eS=`-SH0d^=R8wi-ybQ-et26X+mDK%UJwWPjUm%$rR^8Ci$!@7YwNW=3^(ql zdXP};t{cof6PjouC#XpbTdR#VgPRyk$Qp7JRJ1~)6Jw(iE|0H9I%{5&a>rIu;#fhk2mgci2%SEutV;IJzhX##%@nw1 zD3a)!C}sWr>h?~?NQ9Z4#BGpc=kzeScjhI|I%KDIYS`@TmnaDruXW|=YoDL-`H@_X z3-dfQIQ@N{3u7{#5vr8dqQGjA)s|m>vT#v!tNR4?$_-#em;2}?}sUW zifHV-%jQPWFGAXMsI>wV$tREerkpCYBRemz5GDxV?y1CYAiwo>x$%Zc*$fw#En;=I zcOb5$j4V^UQLeISVi1iw;UTmmx1guTIX)>0XDi31^3L6Edge5+J`Q@GHhD@Ff2*V1 zu+svcv^>vI%vxPr^Ti-u-r3Ro^ywSfP~DRGWYxCsv2%BSf466R?D6*0-LrR*AHarX zx;L^{+X0?$b=^gvsBW+)^Ysk%6uX^B z<7V*GdYude@^6c6n;Nq^YIEs{pZhe^-@71~K1byBKp7d-@#K{PASa+w=t08x5$=@{ z`~iaMwOuW1IawXH#!QXHPJHisEg#VNz&yJKv16#7IV%UAa$&}Adu&i0Cwpsz&0(%W zgu}$#{4Cw#L84}Z7TFzkeTo6fC;?41d2PSzL?rAe;`eVOW8<+VuLn#LP7!hOGPicC zL;bRxXJal^ocBkan?r^jx7^m=r}@aezcKG!0aq`_h?!`)lq(qb+si-UvoIG5nQmphw2OH^Wb1O#*i3c-^XOrAo#r8?F#w!xd+rk$;&7#hZ*xy^g; zJ#8XG8n)*8YBx$IDs^1r#Vl$|2Rk;TxE-NMFimKs%ADR&sY98uYH+m9H;}YSZp3ZX zl3+@e@Oi9&_CQB8SEe-Bo=ew2s%?V%LJSncs8LueH{$H`Yx;SHEaNJ3ACbH`o2;VX zC<6FK`gpx7EKbSDRSF-rgS2i36hj_ve4p zSQk?v4S$MzxwccgVV;2LfB81UZ_WhL|E5T1KDd#otDQfor#XwF&A1H7q`JIwdURbE z6fhP@`g5+&U?}97T6}`@G-oQz>!zg6A1;A?zK~&^SvMm*Q z#tZ*I>1=Q3;N-l&?PZb=&$(pTGn30IP4heX0F6qvVK9`PwGAb5T{W>I+m>Z8$w*@v zT`%{JlXfD1<`yAeZ?t5x`Lfm+vgw8BO;u1;O`l^e67+3=0A3yH?x7S#Z=qS;l>?b^ zK?dHd`I+^@x27V15(?^J-5%$}x_{yw@#$><-gvgv`62c?-**Bc9O3hm2+dUUVxJZY z@cD^Og>!@{WtLSz*_YehHR<1HezP-_-$O*5F#Cguw!qX#p-(8*fYsH5osmYghLb>r|7u2 z`3}96!EL$rs9=QRV--e_Z$ax#GWdk{C1JN!@$qwEE7tER+1VE2&n-C0CA=5`m^A6uzM|KKQ>{|Pwjp0Sks zo(76jst*RdKyc!d0z|356Fe^mCK!mT*0s%-VTP?sz@`}zcao&zs@x)I*r6 z922A-j#px+>pZISv#6ofB-*OpaW$!ltw&v%dyvfi9oNe7rQ@(RcqOa27(O^?Gg+)3 zK*$6vYpsqKYEr>PJA$8@H{rPe%&vu8a>G{f%Gp{gnq<*e)(;~>L zx}Qe{+byoX-W?3?6-cND2HBr1d2`#e7CTWA63zLUD3RC1nmo(C4yc3h;S)Nhj`gfm zseu_;))@+XuK8as;YwY<+>Wn>2~0yme^;NJ>@hYpC(h#numR~X#=4w7ibZu;4nH-U&*02U92TTb%wp}w#^Y9@;fjN3QEOE}0! zhm&6%A0HF6H-Iw*ch)aa1C+I-q@>AU^4>_*|v7;T_UT9u9gRMJ5Oki*~ zQO2(ErhR0|T4nC8S0PTW4m+_Hn1qXN0wi9p@|4~%G}-iqW*+cGVP)6BG#sg9wz_RR z%Q(yPz9weoeKTC(C14v8NyP=oYIL7+Vhq{OTMA%$MwoV;!@-{=z!^jK>>A07?Dk^U zha|^Ly~Q%2;uF^~ut0w&EvGIj&c0d}K| zmmpek?lQQJO2WhJIH(xY&@+54?)ctIu7T9S;SB7H^%wXp;?lYw2zuSJ)Rve3zDP1= zCK9~ATarsBwMshwEA?*EIyB;RY)kZM+|J~$zzKzf+2^O-%3tjx>g<1{(woo=Qo9L*B}!X z&N9*$Sz7LzN1%4mMy{`~gIxDoI|mK-1wD(>{fFQ`d-5*x&VmkVGJC4bx?MOX&2QiV z;S>#*DORV7pFev(++AGUd$-|p#>d)IjT!{?RlmCP623Z{Xim#ynA-GMKI>elLBger zyxu8lQc)^*6T$*GDgk50Sr>sX@D#x9bGT5(Wi$UfOxurQ%6C%|jO!hW%1fK|?YwTo z$*(RVk~Sl6?ZZ#svJ9&oHnTtCBK6Sq^eUk#J%+~M&Ou4Dz3rMj)xnA{!)?GDq>{}1 zMv#iYS}!?W1J;EyW%AXC{=3O6^(hzNzE3M*9z4R{H@*4Z6Fi@Z=)Xl>0o(0JMG;TK{F`9?k53teByl%lpHn5y4Zb#b!@=T}}e zmEv|YLkoTaj?e>0g?&NyxP)ozRx61GDP!d`uZ<%8yuFhv5U3}Zj5kv@UPbdJ$#g>? zFX4l&!6qCA*l}Wb0GEM#9N@P%ChVzmbJm+LD>E77_FQ80bf<^H ziYQJ{7*`qoDl0l)_7x<2=+y-cX z*u%cVv$LcX8>p4j0?8Gyk||3>M8r35GEPswS}tr5(nU(W4hv0kxKu_0u*spBLGhHB zf&$u!MxIYn%?+`O+3BOIdD1+ z<_RU2x*TXK$vD5t$HdGiEF74baf7D_13M-lB`vt0V46Q%Z6vdq^r_W5v(Q=3Ru2si z5BK-?%gX>V@eyzcCnqPsfc5ojc~wOzOb8rA-pOc+^bYcQ9k&TUy18?s=F$xESSe+7s_Tda^d_26>)m4J`YLxW!^nikIG5-E*vtpK9Bmg|v&4w?J zR)#{DT~9Z|sKl`-1V|+(7Mp)O!yq~Y@)(GXJRob|J=|Y3{jYkTTwl+3-3(k)p(rfu zrRP~-^gK&>c`7u$Ma-QH;Tskj>I|3X?a$m?x;_#>C@(vfR$c1602I`}E)g-cv=8#Z zGRx(N64P?7rP8DLi0^nrxI~fhxkpnj%|1-OwH^;6t8?TArRDLAbZGx@Vr;iBm<~6K zTQ~o{7m1DW7bkorU}lGX*3@*nJX``^B#4fTG&VJD0=CJeR$ngb*~^2b2P`rkJYwRl zgM$zb1_dzXi!LKOAt9kppFTk#o9BBot$_ZYn(7F6AAuqPw0*J|bl9<6i& z<-P#KD3RNK{r2hvsNQmVR7C)6H)fiE_1-UWNqHM*G^8}Drs1`1}_Jb5%YVRNv% z`Eb+aUHxkJ!V20fY-3?hb|^i`BC=LtpNJ;OtNjdz`&mobXAM?<(O9tNWM-EXM*`UF zarm<)z{bT$Dq{p*NqpmMZEDqgxMOKO?bJvMxpb4?`hsv!!LzxTd6FdXXq|B$<_FWi zZ@|^U%2!$?7zo}5oksNsU*4Lc{N z@diHZdP>?UiH15Oy@}y%Dowo{eoW3ANXyPph_l-@ZKe@TR)a@pB4<@f3@?K(SB`qd zT4eoEHa$NZAwGJ5*(Nh>(Oas#xlEt;Duyf-YnuA=w>H8;%U9H5x|gxl10lYzl8 z_@6+J+t&VEOuhpe!cr{cyrG*QP&yJzqKnbOcSiwkX#+Q zbk8aV7B^o=h;TQX97wz&zY9Ita4JOS&h}# zblf7ZS=J05>tm_ipJZ!2vWiJmgJLtkq%dCYb3tK&8wkYx^_lhf@4mkFgQ7vK1$d-b zSEn${Q{cQ6z3yJfi-N~)(QEXVDb2FS<(iq93r0*A$nIq62t6H`@#o7{E681kNOB`L z*JA$U2<*193H3Q%Svm9SQPOL0+-=+CBPoyJau4kGlH_KH*lTLf882~5#dteKUfnlP zSZpu45SJeJgRh44JuN~z#Q@MqX8XJ8sAHRe!O=ZO+an)uOM3jx(cDMT6<&N&Si3IuRlH= z9R9Tsv`9PeCvsZWu1 z`A)d9u%W@W+wGvQuefa9_q(=uM^0wsb)Yr)>vg#2H!hW)u*+wPB~GEj6{ zly*pnRhPL@UU=tx+#SU{Y>;Ex2lL23dRba;Wlv?adFFi^@Z`ziQC@&z+RVgcz}7Aq zwz%tx@}AS<3Ho;e1E?qY`T2pi3Q(5;TzlI7+<6(Tc(K`dvu}*=!SYGFv5tr2M6>j8 zhM;mu8Pwz@#`a*$rYr|mI<CR+b(?)?mIY%G|uV)EnNq z!)9x+8G(iyLPYFaRMYwf(Ko0VmF;#qG!DNZafh<`9$cJ_!dbKRL%R85uEpV?<=a;W z1u4BR2@@l`?Cb_Yt9uSi)U3r_a5+*;6yjaAp|sYR&m8Z$fMXez3daHI;F7 zWgHdE(OKLYtR%~&)fyIUZZU6dnq#&TfvuNYwF4ua!wGA5;0s!EOaW*>AaGr zubOy^&)$193r@C^k-n$kr#o=Lk*Q;1DqeK41UKn(*VpIZ%hEQgJHL5nVek@=777B; z74<6-`FGCOLw7_Z3Hs_egrn@AzVka@cTW%zY;(IBOSK*OmA!gLMwT)isoDANf)0aV z>_<|9T_?Gy3+Ax?@qrielgzj+3+-=yWVjR*VLpCnbUZvRm){r1UawFfKjtlEy?R_KVm9qQ!S1m2m>P*gp+a6q8xGzy2eF-Np+U;}wJDWY|B2j_0J)6K&R%Uz zFRzS_h1Ju-KWhQh2s<+=hxr$hk7dszGEFOWi6it+uP4x1qDw@76L1i?clp}#{o6&Y zB4L^J_~~YUX>9s?r$fW=h@;~!A_JC>6<=iNgne&6ei%T0a3&)qDMz2T%M5S|La+K4VCX4-BCi8Xt=a?I> z5!2T9xa7jb;@P8p2R*M3*`NG0pwC;zP<(s42}k~BATAylpPtlj-JutbEu#y3?PBB2x4pkeu<5QcFD4aP@H4+EoFr2K z+xe1yrkCo|p=i_e$BL9){X^<|ar3Q19H83WzdXW=XEopeG?dihf%l6KcZ+bN4nV{g zdRw$xo|-!X9?MgdS5)-r$B2{DYxexv(jsh?y#h>5jL`Y7!aj=czW+Tf*Ut<*#SL4l zj5J;M?e05fKFULP^5JPIT)Z5q1YOim4FosWih+jqxBY8b(Y2)~M~p0aF3rC+#mgnx zzcO+rl>}%pPB%#2+GENn4Ar~ey*l88^JFiaTbwonAa*BS)XP%-l)m50g#*lmgnSwe z<2wpEvB%79Vkr4pkCo(WW%hDB2BpWN-O|#+>9px{ zF@F)0lgFx8YHqWCwWWK{R@Iw*VI%^}u!EKx+ut7` zujMJoDWXE5BDF73vg_-C`n!XS7kqjkVtDyI3V_$g;B}6N3*8y0t!m#R3rkAICng?C zYe46CdV2b{*$|BT$V9r2DvDdP0agYYQY-6nB>{h8^#=3)tmsobp$oFEv$S}EPHl`+l#zL!ls zu~AfeUYOYLJ(U!>Pc4~FBXhC6PKA5iX^nO(2Fdk)-J()_^q`Nx^2$7TDc2}snw zCxdl<;Xr3u%JfzAozup^L$w>Bp$eVNlk=U{lcC1yv!VAe7!pn-E_P%zw(NF1LW+?< zdG8m3&E~~ZoSa;*?eMUpjQM8)B)`l){(07V;gR7LU)Ys2-{6rnJv}}?u5{owjI8)` zYU(2vRs@grB*v@Tn}O*Q59H2~UtDf${#FXnB%>I~?N!_=&tI^eYv^8CJ_%kK@?UQ3S#`v~6fess9&lYr#D;KIv~)eoe4* zDT{~10P)#6LxtycJ3*5-C(4G>y}%`*{LTSiwZm+^_UG)(*gUg-ZJx+4;CGfJ0vz|$<7 zXDQ4yGi}Ct2oObQW zOO`oErK_Ow&N%52PwQ2d zL=VXbNQLMIC~ns!ktNUf>cB!|-!Qq~!ap^Mb#&tJu}>v!k&f}W#b}JI5=86NT6NTH zSEB%EKfr78+O2VLa|=4Gbi&*YfDx)nfgU!hm|mdrsI96>ZZ%v6ZY+o2GN-Q!iJ~~_ z?FnwJGWtH(YU)e)!7sZmHp6Ck@z8e#a;o`>uf`pV^C194 zR^XcrLVx=+Un{Al+fEpR9nV@BFG!TjOT_8A&W7J!o66cSB8A8lY`C49Y0gri=(;{L z$Yn81#%ps4WNgi{Po>a85`6sAor&V(W5>OPLu*qD3rc=RiH{P_zv0lBJOy2tWpQfJ z*FwFFCZ_8~_?M?IcOjZy?SmBs@xJSbStnFPPMojZZ!FiEBO&~?71IZr>}4*{k9z0d z3k<&=zQB5x`v=t>r>I%=5`6BZ#jE;u(44B@gf@A(^*tCXL@X-(;>%BfNXbsrP5&Gk zlbn(;>8QrCE2Pen+tF=m?quQ7+!L^rDLx+ZrmBKg_ejk5U)lGb0ow(Mww%4*{M zT|b29K1V>4eo$VEs%b|{e^eUw6}g_8n2_~1h?Z8jWcP`Ec;&()ZY!zs+QjVuDShE- z(X2WYj#9K(06*W4oT~KR|KpOP;bL=_sKt*^nbfJ)G~6bXWwh0Dx@}s_?dH9S;~9k1 zF_$g;60x{niOQD3dL`(-oYfT~CkIps5tJfqEG(vX_!3?_ySoRAt>wk}d}cc}Mjg^D z`n6ILs|QR*3v7AbeRq?|vYEbPTqNvagP zV2Y{luEb%U)BXFFzJv0MY37r-h5HNgE|Mx7Uv`Ea;ar72^Z_y+oqGvmDkk9AgjhC?)MtXsVGK>D+w)G`=a zzfGd&Uij-z>(4O_<0j4m0s>%H_+DDXq@*Oe4NF8*<@=J*{TOTlhto_ft*Gd+szr-3 zwp#7kiTRGn_O`LXE{9$77wDU!3p+wxQJ4Jsy&{nt+G~MX~KD?2jF8YAbkp%3<~4qSe4+FrBtpyt|`g zbtp{$P~I9er-1k}Suc)&#ZYEoknugn@KbFkzGGXpKb+E90{02qv{3kyc5HU;fKPZU zW;KecUFS=Xc27sO_684m3CUXKEW2fb25hZh!kMEB9)k^bxl!()-Z2{TYvq)`>hiQ8 znx^bOb*La7JhUC-*c?43^jqJ$$>phcS@CP&G(065L^f+_tPH$k>mmGMrq(USdd9x9 zEc!gt;rtwpjWdE>uTT4_pzvQ`^I?l4z$lichKA9iFUwo|dw=GOj>G=F$*M~DC)P7_ zR$R@x&o1ZBD|o~y%MI#N-Ig8G8_;VHTo&11cUr8ECYyc$Bk5ogkxtQ*7LcNx&#rUx z+TiHbIG=U;(PsK8t$ z;>`B9<(*F-x1~ZKyhg>hK7e+8_xaVU&l2ciQg$VU*{Y;P9c(vmo=J0W2v_~AmoQo* zYAD1JdlL$>zG^a8lZ^iX8I0+CHJ9*miOEv~oU5di5+9iNwvVajJ^dU#zr?_H{dNL~ zxzcH>W=SO$DYs8x?^kpa1X>o+{E!+>J;;dbQ+v0PC*GiI|OF> zaqMnIzir?9$X%`Z3iF6BOI?zazFYF9NZ_-(4FH0i@uGW)!*csG;WpdLu5&jp6)>vn zL1dIF-{|<50at=n|3c5)Z;)<}f}T@^e_m2hegbz!WNcMsUU*?hEgt@i+iO&`L3p38 zjPYiyp9R+SeImC@_whu^eHzD2gh(Jz6k5$+V|aRcN=LksJSl8!B!zA6@Nkm?{otzc z3a)q%M#nwVe3j9Ug3NmVvIYU)lgj@FFm5X++l~C#*uAX2s?i<#K8paI_zV&Ia zseyX*=ed;|YC6WJLF4VmI%{022Jr^dzni`!YQ~;0^8f70+Sv$4N4b95%)*`1SIq5T zzsB`#&E3l}&P}a_mT{7TLW%j}w^E%nW#kX?)kWgY0Npr?>3Dp&G|z?rU7+rS7To^i z<5ll_1jN@n){LjH<8?Stpgeg?U1xMu6$dalQ*IRf1%-vPjn~TwbBp_m&;~C=U4NXCB=BWY9XP2Z zMA%oYO8!5=o_onFZxZrH3k3aoH^YsqD5oT;<0);Axsj)k$cu2QK%=rz#}3C}ux^TGS{L{v>|O2&w^Rx>&U? zVlQEZp+iv44Q%!908Z(nmh|n+!N9-(5Dl_&gACmLh|n-hVLs|$lz4r-`}DP)jX)p2 ze?DO~qNfRZ^r7f4w_L(W@kvFAf$#g;Qyt8ov(p_?eVM$2-Y=IW0^Hsbk~B>OiM#PU zz3*NSZVS$qJ*WBJR7nZT-c2b3;8B4Oo6{Vyng1q^$iY`REY31`4ne7!7iOx#Xrehj zMr-EB!}J!Hp&Jc&f#+64vRY@u4gn!OM}nYKNJOBqHV5eF;K~=z1Yt3q&wTVHEET(D zf8tL}8I&*6e?Tg19FP-R)3wk%=R^g}zy?r^ivzQ-72k*h0t1tilUQ|IjE`e2Ys-A^ zD#tS6qP4R`5OW0aJGfpur*CGp&a=MSBWgORWbTl$P zA!JHl7O=NQD>HS9@H{tey5RI3m~t2z8Uh?CrnJu8L?t9T12M>+NxsH&Bs*jdN&yxy zZD)X=Tor#M@}RJi-g@%{xuCR^LfAVXFN)Up=3MIW;Xn2br#0{oTynoXa*S~qa|7<^ ziJ~~Gi}?MLobr2N89&nz5g>eSIzDznS8P!V&bUpv|&hH9#7dWJkt7y-i;4s>+sZtNYGq%N}TK>qI-(yj6YrkuP7@<)>68syttB z>ZEE}k{dV)w2DYQNDZa(XemaOHLyz|F8nT-(LLaChDFn^7LZ-E?5ayW-1{a~|4xkapZki7K0#Zt z4eujs_3@(p-=TS@1E#YAx!OA75cs5~>XOCj1d^tVELU*yv?9z*z13Y!NPS(gZ6>%5 zffLJpK5HV}dt6uF5cjW%LB=nBM6l`Yp`h4&YtIQQobC`mr!qsX|CAyC9 zl*4!5-Ap^V7f7o2i}7N()N7b(8|j23JYt=j+O8BBmcRXcM6HhBw5?8u=DM`5*>&Tx zaU(-tUzOZjLx$Vshr1asQp{ASB7jAJ3X-pdzo% z;n-S#)Fyaqk3(hXR1Bo@eBb2d=H(|@Cr^8#fDt1v+*QI2oNJ{$Sc**SiC z++6q9iLkxQpglP`skG<)BO0kb2@zL6a8pPtlYfDfOp}7T4;Ijsr1bCncrT!SZnd94 z9fsu^aot0At4lz7eG@u@nd_TWauq&WG3|P!hp*F%(~?2-p8ms3;9KgRk3-iUyGO@L zzI=k6XtuYPN6CShm;1A1k2zix`jn-u5+u*|#*0*gcyFcq4R=v1?L*q{+wbE=HMn%8 z1--p9+Z*{C0Efj;8MhT#kBUlw+0|~zUBMjmlH$+!cz_R&U(fAvKRZVT(;;(+z<;m) zOaaI0*yw1*<8t0$P27{$uw`a;{CZrEDscrut4o&xZ9lJ_-H8Hs2CMg2|LEK4YtlQ1 zI(dY-4Id;>onEMUuu~EIrUl-vcz-qfu4Mi|+^dMkab{tQ4Q;=4q*KK!ebpBg*(3A4 zY1K3gvm>e~C6LvEQlQGU`!Jno1^}8hTuKgS>-a#>rvX4H_~>r}={~J57RZJ(m8$b5 zdS!4-s{Its7=?Boqfwj<#)h7Y$nNq{7Cq%ZNUU6CW@4%+->?(Iwo=p6!y~w9bq#RT zM!4N~bWJU%`J1Mx&Lb#)2>iZedLI37yasVAxl1E{)_bTeAXYaVt|aweC$+jKwH;1D7e z{x*Xn#D4LHNs30bzawojBGHy&aH_{u6@2w{*H>pXCF~9(iu)p0#P0paaie#upQfb} zzF(w8X^(V?R5KH@eJaQ?3ivPLV*I~h9($;$WZYwAwX_!Oo;h9fW7a{|DE#_urc_*Uf zT*6i%_1%%I1e33%L;eRa)$INc(2d3MDIicXT(n{NaMf+TzC~c?V8*Vv5vFf+U+{h5 zZH53s6y9!Gu$?^Ft(KSElqX_ERfj+=GDsXE<8?{7OA0Dq6cj#S5^|by-Va(I$aR5P zgGMoJQUu%WH$EDKVB zht1Pi9_v59_c?#rFD?KT0X7O6hGDS18dx&74Ru&*Q$^LW`l6^8V!=bn@BN-Tf&A$5 z7&=R0`MRdFCgIxgX>ixnv{>Z!UITheOiX3@23tXIxR3N^{q5EZxqK2VlWOZm_L^Sv zs5FXQXTQfSH^058Y37(G)Y(0h-sxx+6fohWf$##OPrAbkt6KPO|I3JlW1E7$x5PK* zyMzItkms6uZyn42q=Bv@WRCFqd9r&-HAJw(^VEYt#qTRIoHE*hr}lPhbMY;b-=b^Q zPQP#F_3ATJjSWcn$WD#?x4}U`v*_1(+t0!bFUn>+ zFK(}#_KF*HvFS!_ju*Q_Z_+t@_iEw3Btj-e0-ad3bD(SMAh9)}N%F0c6cj*ii;8ECN;;IUX ze%YB8>TVEMZBO{H-|ci+M&e7#TUx{o{QKAObZl}u_pJEKEMU$;k}mGQ+i;78yjXwt z#OLsGyT6&YY-+?NtFm&W=OQw_5}0n$02!mXa{r=uOnyfg=orW^V3Hs95c9ApEvLH7 zu$;5W=V1&b1f(OyNzM6E`s*)qq@z(-k|r(&t(5a@$XbrWUl~PcLLQ4gMV$-_gZdGPK~Ii1aoH|>by zMGm5a`p7cIqlA)xR!?s|alP4iC6>`7){f8n1wRdho|d*+WQ*mmc&^)$+%r6kbz^Sw zwG6G)t8u!SN~WE}L{Cr12T7dPM2+i5v*w@L^YEdvSNb=1SmeU40?^uB4_JN*gKhR~ zF?p*#SZ0FJVJ``$gd?H>q0-2IUAehhp~cS`DL9W<^_Vv?G_276fD4qZCIBwJu^-fD zb>ZUHaH~Z=FmA=GBq_2IQ|}@>W;N}Q3MC< zkn}Dn+sfY{@c(UVRlvo3&+8j;8>qteA?F&;hx_|Nn^|iR2Rc{JuJxF`xH&dtVeNf= zRvm;Yx99s|LaabkJ>F5Qk4l;^6*#q~fT8N4Z?b*z!6OV79_kx%alI%lDVaI&BW5jI zS&44EzLwXrgGfqbn?$6)yP#%U9*No1zro_==H;|th+!0yR+4Ouux?fO;zcY0{UCN& zOcmPoIFAmCz7>CNtx$!E_t4TTln@$Kc#DDnWK;F5gnPPAhwkQ0p!CcSSA!^H+ed z>iV~CMY6gs@7E=*bp_~a^Z3Br ziRGYgR2)AsO6TV0DqziQqD&pOnxP+8h{po%*^Bp}8?7e1QzFcHKDqEW9udvzyo1C- z#(U%1CmX{lJZ1wKCgw*7fVhFr$K5^0t#m^x=!-a@?=ETUVKtH9{g}x+l=az@*gnD zqT(Qt=T$Vwo{VFdUYG$m; z9XZeB4lvRbFSVy)?cvX7tw&YDAZHzNhsWY0cR ze~zR5h3n%RM;q*Y0?r(_lSlFqYW{|`Odm$2JI+ySbYwsKT-Cv8^D;QvqFRStm@Yp< zBc|3@e?bpQJ!r3(hoNiaEmU~i?{Yidw?>x795nnE>)?9UJ(BIf(noo}oLx(ZT4vdy z&F|8G^i65c`>>;y(OIOGUS#w^K=t!`l;p9?t?$DDfF_5y)lrQUPv^2%OG<#iz#WqF zn6#=sf&u31mWC5-W)x^)YA)Z0u(LxPzINOfW2mTgYxTSfzql7yD!Z|^vN{m)_FTVC z`|?einW=a|vZvAgQm@(JVtI8_Lu0}g5)bGqt+u~oT7=VdxRpD+{ugE`fRA$G`42TI ziZSn+n8N2z*P8lHsHGJ{g@#(_bK|K-D1|-i8+EVs{Nko#Q`K<1x6g4N9@r!ZGhnBJ zd6Dt)2D!#wRE^(53|O9@tMgE(QBqO@F%3PkJDK#8nu}i?EzrW4MlKzN6TKb{n#>?1 zscB~*6Sys25(x_A=6-vCIr^$yiysi(>AZ7a{;S-S?4JarBz{wTWbj2e9FC2B$9v(( z!o(I8J=-k3_)m6x`qf6MYS+B}?)UhQMmU8nEzrh1LNRRaQ8ct4*1J0*TuN1OTIyLM zni^W!fM40jmBIruX+uQ;F-5o>6Jzek)+G4J9Li%6B*auR9w5C_)h65ZJHKwT_9~#Y z$a!TcR5~@-AlpPvIl&2&_kG)0QAbV5)PN@V}GNdAlKF3 zbR~R9@y{a=XphFav8wB$$2=@fy_vq5=UvahvQ6%OyaSD)4$SMud9j^xK9d&>9v~6v zl-ez96ugsYW5+{&@uoOOY|krXW~&9hg*WcqWLQ2>=iZmtD#b}GV8)SAH4llX$FnYh zPeSs_$`E@_#DAeNgW45)fRucSZ}tK;dcV$Ss!W&VAiN#}V4YML%^oi*3nA%~rN_EE zOBxa%k7r9ml=wj+%5Q-qOqUo2<*4&{~kZ z!VkV#lT((4SFNJhbQ`WD>wvSViNRoiXk_2sWoVV_BF#Jzm5k+W$0j4oB*o~bV@8hF z_;qm_z&&^P7vBF>Y)_VrwE9{-`E|Llsbu$K7`gD}2HB~}jV`BLn!WMuSqOVQF4Qk+ zP1$ks;{t^P87?mF3McFcxOe|U2Y?lv2dnQI(ZKYyCnuy(S!)g_m1EsVcS%SKQz*lG z^8=A#MSGlDpDYsU(X?Mm1n+e4IGAk-TNQ`ItKF{28CeIWrk%5y$<+QcVt~RO z-8v^_Y)C}P&VGRTn&R2~Ga0Uj9gup1@KLFYp_Vy5`{6J+uQWzL#%Cw!vd-~W4N3NJ z5$kraV6U75Z+ zUU#1J{FKaoNXsUxfV)oJg@=BMiQ!J%j zTfSqA1m%ONA0ctr7o& z`3Dj}w37*f#tWg}M?YU}!ve3PX|Y72XQkZe*qPpa9S@~Id-=)pBRK9dc87Mi&rTfr zRgd55bPA}L|8usekCZ+O+{O_yFIWj(iZUHc%Nc+6YrBmUdVLUx(mKP6d!3l-E#n=t zv3{DTgdQgBU{b*B%73#^D})Y`J;&E*(G1UVw~Z54gYSZF0R1c=nD_-OswM^GQkqe`j;~yj@!9xvI;gBTQj7jhw*r&+tUkVpkjLD8dOWb3dQSe2NvvA&tm7r9qsN8blZ#0WXoj>j!H0Xdy>a&T&>sD5Ok z%5CVGni+Cod$41WPML1MwhI;iJT@;tKdjU8xtBE1kyjYC#xx#LdDKB!@@*07qXGtO za?l~5wwCE@q^g6BRJXMOn(`kqU5OxO z_o{;ol73!}iswxd;=pCOMHDQ0@3jD`oNqK>%u45)krw!S$VaHr)}2Q97PZVLP1eU8mPVBPBcY)oH!ryl z&4}NJR$dp?2AZ@No&45zB=xFa1rnzq1PEzdAE~m#4y@sHp^ld67Bb+w9~~Yh@*_VM zkaQowry{?jwlBik!=JQpwR~Fts720vuq^E-$*sAB#`^oz(D{Z49e?IR>Hxj;os`I0 z=g=gM>((kD?a4Q6=9`RV{{&|-N+ryW^YV5t@Z1P69`}+y?Kx#Q&KfiKB>%u(c$hV; zze8MO5!Z10v3T|mfX44;iQ$rxhCHT)W_Yc_>XjoVhUCLs{?J2tFqI7ef?XI*Y5v*m5SI>n}Y6uhX+SrP%dsJ@ZzcEhNE_jgRv zT5g}9`*V4SsryWFGz^<+b~IZ6s{l+d_EPq5kSy609}?yFBBbZOL`88#e0ldDz)MG5 z+~gP3Wb}CFYf!j`rlyfJ(juM#8tXc=W5th-w&=|%C}zI9ALbj_Eo%;qlY-bRw732w zC}v3d0A3_(X67!>x>*r0s}*c5U;pW4^ot@T78?hr@NCl_(Xdr3+v~|tI(oCJEhfaZ z&37>|KxZKm>cSj6qM8J|Y}bpKkz>ypWxfuLwEjNgGA0W!!V%onE_^&(9WBYNz)(?G zN$UAy4>Y3)-G1{zIqA2_)`~QH4TO1f$bd7>AV?eR<>ET1S8sKnMJRbT52z%0frbIO zi=fs%p|fTB%Y8SYu&)Xu!DHqSVyzK>NF(PrI?q@&4@-U9GvY$&%6(H%TI_eYH9ZnT z{kbaK+LDpmO1tQixAc2YP^XL1EoK~{6Eyw$>kpt5|uNW zIc7UY6zYq{@A9@y0pLp-C1_5&*83U2^frU&WAYVm_}2cQKPDF#6Sp+;$+Ldsvk5az zG=zhTLr1E(H9z*nvD?|9$=1P=;pW=Y3oqnt;FxbG!yY(HWn5f%&RJ1UDWhXzf-oqJ zOslc}gW=i2LYa~+6uNR^E3>DOo6&WFd*GO-=ddt{Fb}6F~zywn&x26 zW-fPt1mbszR8%cm8+ZSjnj2Z*`&m*)-|ItuGSBtnVchS*mC+J(!2O1MRMmQ>9yY;mVNFBbAWE_SYU1lAk+8sb+uXn4FQtT{c+_3wo7jr8k!P-xO?s==r`-l|H)s^oSU^zAkJut61X>{ zxW(Mh1_6R;N-lF7z#RQN4D}Qh1OiFjUM1Y?E8oWN=K|NHyF1UC4-q2ENC|lL$ldkj zT7f^M!9D};wFUd|!)`RSCNEK5iCMF9G3VFTA~wn;DSE_Zz8#QtflQ(>J+A#XUg3=yA zyCmhx1|%(he<7~i?=)|i8cc!)v>xpFzKEUJw08!le`Pq_4NBS1g1`4Q;Xx=-rwf1e z-WB=h$&)WnWF^GZ!(Clzboqw0D~#E81Q*hDHo^e}>~}>38b+J};PJS|NQZm(1N*Bl zGS{&BBTZg&ab5riKaWn1cQ`zBkk_63@~y3WQEHT!af09yR9sxl$6YPp${7HHWF3Y!ETmhXZJpUKPg#2qzXlRE);$Aq7F(w)<@zPj`oAHb+;!( z9rM*!0_$efcNWzD7(LIOheYYUfS~wJ1k43a?D8I+pab)UMh4SJ6r4%q`1ss_g333+ zBI6?qH&(_zjP=v}93A&w<8`+cwoNsl=yyRXKLqqrfNO#^pn|uT{Bk3sq@<~;ihv1L zZ^=TV$0v{2GucMG}n2I2{VU^=+3B_I-+{Hb&`tx%~K!fb|3e z-<$oq$6*z~H3AsyBj$HpOXM)=2Gq`gKp%M1fMVKvm+RAQ;8jDZz`w_-d{&(r=k3w; z{)BR!pH}-bKLKU>63|Gludi=z5&=H?I5^X_wkd$l7T5$o12AO=_yhpN z24g$fp8!>7wm_HdXx5BUun2ZDb2|^LE@c?sb z+!YeXtW~DpOghg~s#}LSF93!8CXR>szd$MyED=!FRY!4Bvp zK_Jf>`2b8C83sk>)#Pk-LC05?b0-R`1)6z=){|$r^SxI~!mwJp03&S-QBHIubSRsoMS`)i-+j zPmVdLNieFnE*utau--Q!0HbuVZ>;pZ3lE}i3v*7VfABpG3h(ZIEg{uh4zS^%U4us^ za-;*qoUe(p^r%>5^Lo?>zL~MSZzC!b3Q=IAW7y#F9VmQ=)Ho4t=4ATG!r4G42;EZj zzTd@aU^lJ;E#`^@%lP2O-!8ed&l5S+r8l=;ag(MU_1#s-yDg@+-W|_{S>%@s^Lt#4 z9R0We#E9hlUd&tLXGjcX$mGpiZfm#qnbixZ${r}>ZWjVORRp(|r^0x~<_hr1oi1Is{hM01Hn_ z2~XO6rAB0nXJKo?z)CCaOidbi4ye5WX|k1Pt(7M)%%v2N_PFj>N@$GKe)v=M;Saag z=v&DQaDL+p(jYWg*qR$zUm@x5{m}%Ckpz`LlJn3U+R}PytNhw=C05yIgZsy%fOMSB zbOPYbJ561G?0=Z2Zg9K^bmEH!S5<;1IHFfJ`n#XdiIJe+z!Xp8|^ zizi=DAB8LFggTxto`u!SR0;SvG|-lg<`=F=X!qXOsIuM{ieZxzKmJWXX}gy57nI16 zV-`i~C^Ny|#j{uYK1KbT6cvI5`0%^`=fhO$V5;Z-osZ%&HUK}D{hxO2DX<8tp#SH` zZM`7*UZ(dBdow58*Q}!Y&CgvZ{HA}Ex5zzctc(ncQ{$RigEUJ7FYb=Mlq0q`GZs2GrSryf31XDVTMua>j?XuI zJ811`Vov@E?fgcYe2D(^@9p^?xC}_o;&tW@sf&;P)t@DMv+}*d@NnJ`12F}k2TL^E zE!j%Edd*S2XY%w+%|W~(*!hD5moGKdbId1?>M`IpIja6oF#EX#o>)t$Gf2%2LrO59 zJmpmT*%q#nVowvI+)e9_=iKIHYA@;g-diT~0Uy#ISoI_ns@RUs)!u3#&1@4fysE6kAw zMPB;&Ns$SI7jU=w=yc6-@0-l9(~Z6L5E~^j4>f`Tc)q=j06Z<0cWj zxfV<@gA*XdNF8NNd97`5_u<3GIdx*%os)Y0{j7~`FsX_Etn}Vxms^OPmc)FnSMK%6 z=83d(Kj{|js=dCK)(6Fm0(!5DHfiv(P=mwURQ?1*ebz-2BAj5+^4^)D z%GN(Y>|y>YGCnSC)=CBMBv{1H*T+o6Nov_vpf)3C`1r8mBJK3J*5j$>(r2hkq=e!w zONXlL;S1ma{RZ|zh}L>!U+!}C7}N7+mfv%yI0W7D-PC*c#_431INoYL4WdzJ!%&P! z9HNv~@00fRrUJ4$(t3B)KBoVVy|)aAa_z!K-4+4@A}OgNUDDDBNQZQTN(~?&-C&Ja zgmmLbx6+M@ba#W&jdU|-q3`={x9)R(o$ufGSBIJBxntFJt!v2?U*}XXatnBoLG8s! zQdeMaW+Q?Tq7l-fPbx7uhp>2WkBMiD8EuZ0{?5r9#Y%56J|OTY=kd9mPlj6Z(ZpGl z6~ekwyY<;bcSJT1X!V@Q-WALN*}d-bneChtm=GHNJn}3=Q3Vx(&IoCQpGJ|b{<=#? zc){+<k>pYtR) zN}0GZn5#$jS~q6a`io?5o-#rN2{HGscZ83PJ3Ckgp5S3~d7TS^=De#G|Hx2Oij0HVW4VOyy zP}nK#@gj*{sHI+)L1pSGM48xdpdo_u-mAWs^$P+8My~Y8WZFDgO|7;sv2NMT)zOvT zo$swTcXi2&D}1g!JV8k20alg&xVs=?87m8>-bSde9n_FZsOfFol$B)+m8((eJ}X`^ zW6PB4nEOnMJ>QM|+S4;dg+zm!r^3qbVO<>}nqwlbY|OG|&d1X_g}iks;fo2cj?vr( zlXQOj{no0hp&>zXFS7-g?hr@qzlgSQt7+k>347+D8#dhWaKlTo~SJ6(_s>A2A8dWGB ze32-!D07Ob4oP04NU>c+6t%2Wa;90OiI>fUSa)Wo{X8L0W`|LAZ>w8uroaS6ROZ{# z(1Jq|EsR$<&9uh)%<1McU8PUOf=3!T_o!aSJP|iO=CJB4pHx3ue?*x4RIs1MpA~H42Y-)WNod!C`BMlxp%ucenT7 zHq?pEF0Tmq6hF*+|lHEY|kV^>>7GzQ1&O?ARxhN+wV^V%5=6tNU8H+_YQZ(UPTyU`2RB zaN0B(&K}ZVL6BJYA@W69X(6T>4=c@In2cJr;zGj}p(c~3oplPac^SB!fgk(RBpI=D zYrf^}Bhu*QpytzB~S&TFUqkQw)b#IXe^l}-;jeZ_V9P;i~{o0j#zl?%IOhfL|#}C1H z<@}MBnW|`2gpG|Mt~K_?YADHQrtv(YwCEPLU|U#Qirb#<;9mY{-y7J@W6u7M`$@V* zNEW7)ZJ$^>ENZc|yfj-HkiGP&ON)vp-*8GhYJ;VIO00=NYrEP!hcLfDpBaG~uxuJA zyPkqtaNIH-l4O5U?r1GED1|bV-(9n|Abs0@?!=D(4XQKMgAE$~f0fRc?bJ-2shP#X zWL{C{vl2`*wYI#*nqE&q%RonymD$HuQTd#Buxg>GNaHlf5Fa`DykoN_r;uUn!u`X0@$8R&f} z4EEI$TVO!@&y)?$QTFrVbZzn7cU-xVUN*I}IK+C*c;Xo~?nS6&{fuk(lA&cB1G?27 zQ-n!Cbwe3qpco+H{kJm+uB0}^%}f9HpZ|Yb6G3c01v+z+DXBU-V9uQ?8I0JO?0#KZ zdUla+k?g0jI40ac9IXU5_`)+N-f9>S*zY>;l=XM!+HY{ww`gi=&|gc6#VU!5i+nN? zXz=sQ{v)Y)pK?sCW;ZG`Qqe(D{`G(Z)Gmuwe7)t`YW-)=|I5z^Be%w#Om1Gm6eRrW zj^G|dX1FjCVmY72{nyVruPqi#n{h%&OYM5Z^e+2ofm-GXw66tL+e@JM1#c{`VfjNN0u}3g-*$%IADY^wjixDPNxYagz62uv=O_*#t*4_u^y}EWS_p=*{$B zlMSzdOZ7;6A?H}Gqds;kxxG0n(>g25C0f8^@3d}Y)KV{ZC--~j^?R>32Xm5)ALDj# zIIq7&7q@+>|D@mMS(IE7>sysnc}AeIxg(&20?*cGX-NpWvX2X8!N=*&TyFb?(@@AQ zaaoBgD=P~N>mA55pO~14j*kA&#W7pn&M;YPwucden4C7l$}0H~`Roq;O;k{HnhZS+ z!$!&bQ-WeNd|swlUl;!Oh>}HF&|078IB1@nR4CWm-r1{YTQuKDX|_E2S6QL_V*3Ya z-^Gx?5Dxoo#{vTpr$e?=7+k_jXXL*SZQf^5#0j@nx8E>fV>*FleffWJr2+9EO2QXv zj(8L|zQZu<`uciM!?d)t1d-EsvCm@jGsK%-dRz=f7HTP~I!>kprM7=xliK_GI58ln z+IUXt%T!q3G_532HxH`1DUXYsv_|7itj<_>3?+KmfG%s|qNq9Oqi`ipYrK7Rt5J2d zvh8l6!5PMN9EV6p(Zuk+duFFgf5c+T`C3T24>gS~)LxfMBwTZt4P=7yga9^eqoxTZ!Q=OLvLu36aLNmsButAA9V$Z7^EfXO6`6gZriw!wuGIPoy#p) zbp6#`dwWN9C5y64kCuVPb2CtINdqN|*LlQC=Q^Mg@?Qo8%%9wc!_`j2pxG4afa z2Wy6G$JZA0h@}&}&%Lrt?2OK0p`pE6QR0O%m8$YVV!hI9V`Z}3k zoW$%YrqGunpv%&X0nNC;S6ecl%WOyRuZ-%uAH*q0K*1a>u)O-Ds99Y8oY&e;&;ENC zySppn1P&Q%B9PmE$soDA@hyP5a&eM^%#UoJH|jG({>w~d+n|L(5?vlfQ4ZRKmW|Ij z^cWvcA7A<9d_JTZ!g74PhJp%_oI;cE7L{o_J>5dOFqe=oPfq#HY+4Dlty03X!&r?& zaz4-rknCOl2$5Mwf~m8W8jTg-;@YRbjfA5r0qzm%ocC{~U8L9i;t{iR~Mkn>1IiVqs$k2L&OxbyZYU?%lh(_qMp0 zl$CGeQCgY>Xgu!gJV0uaZ(YlL8}#yaJ++~)x#Y7V_WZUMKm0aBujY*G%=DW$K6~k@ zkL+5mbyA>tuQFzYh?KwMw?uF|GIR2mPp{` zazePRTH|2bImc7S4*w^w^S|afIas{Rb2m37g}!ru^1BL*T6J@E1p%}nye~w$7}vM$ z6M4IZy$B6E+c|kT))a{+nCjZPOZ)JQrqr)XwWl(EyW7E^Mc^dm+lH!<3e6vNt3nBg zE*jgFVgC1;{lh)MmLa^XM@oqtC~06hE}l7qglWI`p1BwyHu)_WcQw*J$nswczT8uS886c;`?7O9Lf)7xR^?G7=`7aI zOYv*iqCk&)Homy%3B#5_4zjeg6nb0>VO+2e2;m*KJ;fU?RJNb6fsQsfoIm;yez__g z2<}UlE?HY!Z&a0$0`ZlZf#HUL!1Tcz70AubE)&eX{^R?r z|MA&=HH30p1uI?XJzr@2e|%ZXW|%&BZcMsQ_{Z7%XUOJ(l~w+3e%=oQ#)tOfpCFMy z-KG-uI^^IW5`4$?Gr5Gn8u~S+mXo#E9l+)rtE27ZKp|MG&z72O}! z`JeCofAXK*^$^@-)*y!ln$%3}>~;R9&R+PJ)g;FP7|aUn&Cy&+ysMiVAbLRU_(1U2 zA@!e;QgRWpw-yMkK#m0v&KG8h07TRbb;T=J{4(#OC<;_+Yp>G<&`#|Bh@DEDcBSON$1Ox>GC`3$?f$l62{>dK89uDzK$P2uctgRdgR@8U{7VP#`$?dY)Ucy}v7IzUrRt;}IY z31GaslE3_cEB`wj;MoKVn$TebRBF44T9_fQzrSz1yb2m3&?WTBpOW`}M1H=bjEoFm ztoxWJTK^^Z_uLn^0#|dd&Yuz{rvtjk!ocwHHSHT2_y+$! z@cNfcslJ7*g^30dMn>p1QS@+um6o{pN#~83HaH34>_(cmZ<9jDIOvj9l$WcjsloYQ z{FlS=tyJ7t)zENxeqO+4_{NnhC}@~avR&#Q9zIqPx3*pa_9IAr_6-gSfQ{&4d^8Fu z(C-WKzyieqdnp9N7Gn4v!dP^G3=agI4{F!nb|3(Z2AN+__TSlGsoLDwXir_Ry!Vjw z&x_m4$B@6)jJK*o+mAAMa<+e@%mq9gonq7^tZGT9jK_u3vI+{04i0*ip1Xh{CuL@O z!X?_TprI0F2+y;-yW8JE+0fE*DC%K; z(x7_a`W=H=ImtymqO`(pO-GfDt}rG!lKo2ctgJxod-5HYO*tEbE(OH5(!` zxmL&`bB`WBxAky_?p8BSt;uwSeB6xCjNoczuwd}M7n*mZm9Hm35< zr^kRxGw_}4xs7KdnWJfZ}u5YxvJTm z4tOjsA^Axaky=Jb_A*5LR7)y*{tmT==SFqo5`CLdg}b}?imkU0)H}bgtQj7f87c5m zk8qDT%r_Fe!6zs7Bj4IL%-iiO-X?1soONMxHLt6U_1^DWQLnGOxm4zBC>tuuF#YxM zBBAbCn9Bf$PakG8kn!FxHfv`jg4*P?sWeL<0ns^^c-A`VC0r0 zWJ5kHl}Dju7`fYCTe8@+6FVo2$-EFAn^-Y+f-eAl>f02M-rY+tGo`gvwokcfa zW@`R2e$=XEqo5fmo~Cmd91lbIz1b@izU9`?2Z+Y_}wSJ z!0f9t9;C8IqI)*znsxts6bx>ll@Fs3^*-2K2;)|Na_|Z!##4bY8Fsz(iuhQn5(1%! z5EFix+hV9I@9r>icN<{g+>%2*GRr5_eT^>7sb#IQ7i2ve{w5MD6^EO5Bz^0HsN>{Wcc5xgBXA;Z_Xx!q#C=-3-}<0o~{ZuBi|cbGHvo%FWwrKV;mQTrw709&{D`-ZWcz*0jmsxt2I@^U=um5nf*~ zWXT|LT_4+9>E+?$<1@99@Z8@&j>A~0`WX(8JC2*aVg7`E@q?QsDy>icIHBCM7M$Bg z=j7!J5~NGA4a&Bg31cnSTt8Q^|ad(>94EksV+Gh9@ zhU;aO0A6A-c(?mU+Ed!U$A}V+2eA1Tf}h09dEI-D?u&5R?vG> z(bvXn$Z2jQS*@*?(j$NVyT&W$kxE3@)VgM@h}FOcomX3mRfwUfe?OZB!_bB?CiXz64o6Z0r_CiI2LW>0KEy<-4os*4h{Yp z4R;6VLvQdbpsfR_Ls6iQ3_0i2lMr;7c0Qs5iZxRkp?DrOFNrcVv-aQ%AV8o&Us>qQ z+6Sk_cfbEKNx?x1WJ69MH7y8kuEZflKiy+{8FC0nQqV9g29H|AbFvJt>#g@q@3K#DfG}~*|p{JvpDmE1$rI#YAh=?_GcKeA73TNY$L;0c}++*4|eft zQo9(Jk|E+!J^ffTul3$+LV#Qd!rfwJ4}5(C~q ztGhrlv&U-uoNWaQij#fT5%KKBLgc#S`Qelqdz>)(KnbPj*`@FDQQ z%1TQc?TGO4Q8Ai+F`kbeJrWTS(MVT+W0kH~WCN>K<|XLG({%sxj1D}H$j;X%Fe_$? zqW>`rk0~W$x#7?e-#g>~9FhOa^Ydr4b3T9ha&2RS<@Q@Kuuz8w2O(Vn4Ut^tbV@PW zMK&XzyK7#spYmZWDpkW(kD=Dq=;pl8%Q|(s-~SW_E+Lb?YePI*H(uoGJJc^;Z1RhE zlh|@9CIMXti%u>aKVZn!A32!9DA5Qmb0y%^t$~?&d-KU$SBkW>G&7(3b>N2~v53a! zBPowRe=$jpn%}8q@BBr4zhH5R5wM7fgk%W18DibDP_Y1EgKX6#IXF8rEz#i-5vjxD zt1n)?())1l7Q6*$3Zu7%Y|$`8=5i!3m zMMXPj;57?S_y;B{i(&Z=*g#;jr4Ne}faE`{=sX-E@K}bxqqDVLfw5wyZXei0L`G|B zkV!We!(fjbsl2ti^4_Ha;n7f0`P-mR6r_)gja*^!c+w!Lu0|jX~~H zQdSlYiv-RN9$j9ku)Ip}34~#N=^D&kwhu>Cvdj1Pd`@L4U&ZgZ{EPcx$Y;6XvK*tY zb_R~5qOi1bBj6~a8y{8|{x86Sd zqSH(N<~btpmn9$^JXHd*HH_0dz9JCeZ!YwuBqcEx)l)%LhAub__5}(@UUNy|$6{K; zCl_D9kFpu&jqF{!h>sKW_clEf_c#r^1YeV-nKfvYQ^5<|$;GOT<|qioSirBqrEbK0 zPao$#pZ4(kjfG$tNp6{6A~P&SB5==OxPY4oZkSTgIdfROk@e$%+czW$E=3i>>*9;& zXJ!8m2qaL#R3KGoQ0i#swCig*A&d3{7qO5{*=twHuKgX%oJS@jXJ=>O z=}SvW3cwb9d~~YO;(?T07)#0KN6deD9zht06-=+p1{Mw2phpMm$|KZTv)BzQk>J z_RT42CCQ{@{>jMF$3Pi{gsN(HIvVVC1dml1zWfWJ)3T+-9`|NSmyrfwh>bRS_Jorc ztJcPfn0E14jS^Qz{DTq?pNzy;)j5>JKUgSX_0kMF>8r)iZ&l}oc9IcvCu-jO61=U} zK;O^lFQ4Ri;RGFX+II_@3mnie*DrAEYI|SKd zp9)MQM6aY5YoeW>G_>Yb)Xc;XO-bYHX4 z+5~>nmakD!`HJA~UZj_NxhO*zddbfFg{8<=Xa05pv2K=bL0Hz1>F>LjP}zPF@4k)$ z=bfhJo*}ia#G!Cyws=wNQ6s?#0cdGEK6LW5~q73@YfgY?JCzKY` zOC&3B{k>y{h~UbI0V8eBLd13tzH}Y~x9rOSo|Qh2^M}RH_1aihN#3#CKT3`fLDI=$ z2saVV6>@xQ6%BUwQ7diN(hH)#;1@K>mpB~k_9~?Haj7V!8sT6~B(;daz>K!s^vy_< zsw+fsHaE6gYpQ(%W5qM{ik2UTM*p#u-6XmPPpQxg=DOb3Sp<&CjpF@J_3odwG2lqs zP9<7Rn^vooFAd@eB_`Qo{css_0bp{NO@jX zgs#TQLR5|Dc!uzVReiLNX!N6t3Tzfu8FPns3{I9yyh{A>P|gO zv$E$OjuK7u^HF#=;OWuuoY%V}%qU#8^wRFc`^tS|7OkF`2*_2V6q13AUIHKw$a%>s z_Mnqn3Do9^GWZ{ir0sCa%2n8T-fEM(Mc(?Np2@cpJL`2HYJdEirE0}SN?u&k;ZqIN z(mYzVHbHOABs(R(Wv66>si|86%NqSqmB{Nc7hiv5yk&X+RD33G1Lr-)eEPTst;@`o z{y3%Rz*fcV!)`$WnvnfarGwjt4eo09IK48bbah5RMAxJyGDC-Qm&T$uGty!i>v!`Q zSqJ)?G^OzYdEB+xZs?-%S>(p2L_g8|J(|hR;WtawY$<(O2fN8Q-u~B(IC+*Y+?~rA zdL>ne^E@==V)$!IhZ_~`K?H1juG>a-lbLEPglmY&>v|>jrf}~d6NHgP-p3IKF*vvJ z09MM*wF;&jt;`+w9(QZ0Of1FXU=utxZbnwx)#;vy@5Xg)dNy1wYh;54nI6sVlcU{l zu1qag@u_0%W~)BRLF)y5NpGjZmMtw@T?k*-II*>uZ#ve`4_3apW&st|c<((6h)2CVJ=qM)Q-{TE z6QjTRZpD{+=d^nYH%&JL&qSKXc`fgftd|h88KC9`mu7SfOS9g!yWcO3z2I}WhFr%_ zF&?MZMDR`f1kFs8XssyLuv;883lg;H5=9YK-IY1TbAI4LJY$8bv9r>mW=>4gdNyri z7`xu*O}B9S%~EtO^VZR@GJC#M9$Ss6thha~^pT8!%ksg$g$MH>we&dt?3XWkSgfp5 z(}lN;qxh`1Z|zquoNP_j3G03pbz~4P)iPMHMd~6nojl-aYExD@S0qfAeNdwHy~bqM zw?}mQ{A)#lJf{Xh9x~7FeCq_|p~A&Z(cy*_hvvsn1cNq&;?h#kx8`DDQQ$w^k?HjR zwz73J5tS>DsK%wo6BW36qQCl0;wwROr9o0R+oo4tq9Mj?%FDybqMMVD90{+g$ITcz z+A-^wJC=gJ?piTaP-yB7|M24A2SO5)uhEa8 zUrtX-Hl3V(`wW|e4Roh-Z==LpqWQ-1%meKA>;e--bwxLi^5q+Ef5=S9@tj>4`)e6V z{c+*Rw%H)(+;qW0pY4~L5ryK|5X;R5IMR{%YI&lJx*HXvqHQre-=eo)5xLrW-s$ww zGh1hqww7647iryGEmt5J&xd*+hiiA+A0gc!HcqaxxcFyC!J9&vw_62Q04irp>3L&_ zP@ZnkZlK|6oAMUdK{R0YWt-JdDQ~kZ+lL%@r)qg@{FU)G=w7c6-yPZ1o*e_#d!8h( zDcj4meByP`QX>sq4w~Rz1ow&E)^x%Zw2C<&xNXVC>MG@kuWxNFi<;#B8FHC7Eq=Z~SH(O2?4=@T-+6nX4EolzKjA7-=6Fynr1wr6!TL`vzM?t2b_qtq}QysPHoVwpvN95uzlnIqP8f{9vZ)Y6BM4ZA9(p zR4NG-6_tc%t==CN0Pj@$8>p;py-5rye)XY`OZd9kh0dk^_Y*U=gK92mDG<_ESH+wB z9yb^_U4K~CU)U~&BkaDhvpkdb$D=)cWFUqb5QT8|n_tg!NHzag4!UxF@#I}t`Lq{& zwsg8x<1D7P7G^}Fs4^(pE;4%F3~!?oUNh(qniTvnmFkmen3Q#KZaQs5?=hROB|E3; zVilM72u0Gprp5M3ul;z1^*`LYBlf9=uj|G$Qg={GOGfX&!<1QFvsswawAiqdmD3p% z>hq|oHPNlE?n_AQ;k;scb~#VsjGyafD_OwHBCWB*j~nq?o^EJ!Gub@>+LsdUPLhEp zyC&hw(aY$u(ERM`Ja9xrPc*RuNpC``MXzLZ|M%5N&?9wR8HKs1ON}bGeIJUydi7lt zan~kVyhe~k0o@^bb@>}|D=1}iA)QrCfF!D_$Ybu&eh<;$U||XKdA$5vD6g#n^W+ld zKm4{M_7f=efqenkh>>3G%th!(Eyty#eJn=v%E3if2{aS&7GE&40ei<1l5hr?=OXtnBZzUA3)V6)}T3 zft>|CV_hWKAO1L~tmmOdKdCl#9Ku(6>hNO(h>M|2FUw*G88OX$%P5}=bwW+CO&anO zv?RyW=eFyMMCY!ssfjo==p&S?zUsaed(>FHGW-Rh_<1b0Q7e1#sLH~eOQhXiMtRbE z8Akaw9ez1P($VhYxMh8&k1t=_6B|1%D4Gy2!TV!9Z}&}@%!z39e20A z!ehJKZM`=`i=B~CmLCJ})*Y1feyP||PGKrDH|4Uur^^8&KPp!{`r4_v-cMITnFG!} zH$1vkvQ1A_#wv?f`ixGN&lx9EAJx}etmj*qp;6c|`EaL9O?=t@U<`f4=Tk%Xd*pCQ zxcUxszvq;%$w8S3rcpJ2z7l{e7RN@HNM-BWzQ#OUi4UV_+_yY#+}dcl*NY5GLDcA8 z>dss>D@YGzuGo@WK$6g!qNc2P-TDmZ_?l9-*rXXnzV<0Ms*~HbUo`7G+RRiPrwq`b zw=q{ibY>AS@2NAdf0}&zWy8mCL@?QqaeG>1L0Rv}=dXKMOBLIrdgW$i|FSxlhR$)jtWW!m77!tcsfiwZ z{|ad)-u^De=`+-L!fpa<`ioOL*XEAIU+JznojPO0{=(f6CEFPq$||ouyH+=lFBtwg zdq?Te)GNP-2ZFv~(aDyV*3+j)8Xa93XNr_#3l=Q*{GSp=4W~8rtrY%o$oS7gy{!w7 zSSXx^vgp|Nq-!)iK676trEL8M<2CS;Ml0>6#%r4TY;|~paTOQ#CVDqCgmWcJ-PX;f>zY*w*y00j zIG8Q48@ZvK41=zcI3G>UNL-?1NE+GaX%Y?2u9HH>V~ToQVbd9H+r^pi)QoSPcXdpJ z8gB;ga?!s$`_-#gK^G5dmP<1;TF_$%br!~=hnWC5ck+zgKoQ(nNq*A2<^I=>UgA6!cd_Nj@G zI*0wqL6xjA?}nDFjSP*^OqB|IWRT67rk^pY(|TM=sC}0CPHm#7aoDGp@hQ^Jp@)LnuBhGrJZpT>f~aP+toM^9kv_W$d;Oc8 z<7OpH0hk4)(kfUKhTj^(m{+bPnVPf})fmkdvvt+`Ocas$?0s2C@Mo;soPsXh(M9q< zLoXlFVW{8w938;CJ+mhM(r&O~fL z#YUbua*R5+oA1EgzSb}(Cw8F7B=BG;OUt0y6K83Qsts$G$vLK!fAVwIMumJDUmHdy zIoh0aa=pCiJ@JFG;ScVk3gA<_O>SFZ5d2_x8i-@T?f@9(+>nl|uQ+}CDXTkG? z6m~r#{NWntZ{F5^{l@{iau>7*HIo%CiHcU0(aHtbA6c3OZijm!rV#|s(}&*bCfs|6ahH##Lp5&9YRHp3eCm$$E|yf^#mYIA{OoI z04uJ4g(5;@7^?@+oSL89Np7cjL?7omuHDW0hdVk|{T5&YNWN9h!w`3=FgzIUCKb*YKH>i$^e;fRx@*ff9{Aif)T z{KqhN^!#@!tKUN&<2$I4V!P^^8nCsmUamslcodJ7|0OOdj=11RK;@tgAChoKncHG- zE>WlHkGDO~!W{|tfN~VKEWaf$l+&Sly9Yu$T1u1ZKrzwG)c!DOqG5}7YV{f|Z3!{M zd%^FSe{9ePWT9zOm}O5oqjVSMxpNHasqIIH`=JEb#^2kV6QNHUMwr zL3B6oZQ$eMg#KM}uKg|!v<+rBRZrsH2VF~pVKshaJaV$KDy>sOxKu)bF@6HTf`R^{ zl*yz{mdpIEQ!_up77}$>shoLDRfzH*1bq1w+Lo&1DC+zR%FZx-o>Y@dZT#bpJywd;}i-;lmSC z6z8rmGFlAgTYxvX|6SH$?Re=GF!?Z5Q*ZUh)!bU`2mtm`*Mxc+bGyX7@4|^LG9U|C>snItm9(;>OxKx zaBr)MinbZ9Kx%3a<5Sm`Vcan%qW!Y6avP|3xx2c0f)K(X_$!cbJk}(DL>!8-AgB$E zwgvD4Q2Cy3M?E>1fs>Hz1T`7G!jQ5<&=;@;uEjCL$7?+Z4+qEt)Xv@mv;i6mcj5cS zKvDpfv=?YlP}@3QU_ILk4fq2x9!vEcLxZEUOfc#TaDO;Ajc)8d^lL5}+4?0gk2#D{ zP3kBN%JPgpj{xj}?R|bw0SXBvrKQurAUKMDcc3VO2B1`Af03(AC|JpnHLXPEiWTXns+GKyGr%8ciX1jBR2D#lkTnE4j<3hdBO zj_T4ap4ic5N_O{_v}WM_qVbG8W)VX5pqZG zu@AF{pFUvt8wn9Q?|y&5EUjd)F6uyYT}Xt1^P6^e)>nrYbqT@M_tcd2Wyk@R?#whT z-#*_#^-Fe+ua2;n@6Oaw(&&)NI*`W5pzP1z+Ua&nRrrKtSFytW zHaSuVlwu@j5*+Nt4vY(ggz9kjApBsW|495h)d+=j-d!^#AqfXen3109Bj{_W!KDT{ z5e9m(8-IRpgD1E{_+NE@5z^p>4S>>t&}6{>*F9AG1(o-|V0Q1Yj{$PH?%DPmsEwte zBDi(n^BOTP{7xjnH$VU+ck3k~CN{R3k`QUh@$Kt|Y_E5$2>L1i`DTu&23J?t^W?k= z{Cl@;hKp6;aza)MX*1z(AFVx$Bqk-*PFGJmrs{l=c=7x>gJxzw7?Dtd%RuA&$BqHs z6`ZD9FyEV{7n%_8xv#J2*l2xar2pv30CXdkzY)6E)>zh^iL$`SyX~KTXLoH1?wY^9 zKbYQ6zpw2O;1{n*K=6Bfd_07JiB#C#5g<>3ix)!(?p*u*X?c-!UBGw(7?V+2?dHG@C@*rszA_>6?B1f16`5-Tm@F_ zmbSJP@F}gW6DE3v_Zh*z0ky0A&AgIbKUUoLk0-oVX^yWy8TH<y3<-&Cr+4<{#0rcTdd{JsKqsG2WdCfnlC5UMr4sJp_$QvjwP z976atC=m$!u@3qOPH@}D1l^E?09hFF5z3?izx6ieMC{w&PHrK4K5(8qzkkAL?5Lv$ zo}=sPINS~mrH4><`zKLvh-45>Hn68IT)LDN@2KviGe||bj0l@28f65Gs zo-fez0OSR?3dvSub6en;SaEb=pNO&23e>Gr=i2%P6Eph8&kf4y5shOk^Q9bA@2J(4=lo(~K%fY^Ot_e!X4fh15kt6uWK;r$C_ zWV*!p8ER!R%hZs1eZrM5J5?-m*xyLg8y?;C~a zx;Xl^8CTOMVnUkOx4qYaV0X;Xj>T3WJzjs#T#jl2uTPZTOKFaf$mjaLNC;A%s-y$hu$dLIioE9&chnBW@F>z!L& z)1h@N;q{pk^6XQ1MwItP-5V(gtxNPe_pV=`k@*qR?L5Hn=iH<+n-}W04{a_jrqTl5 zp|Luu!A%nkH$)5c^z_U#Usay^`c@ro4T0S+jF(Ahx6ia^ToR&T#mygni=b4mWlR~& zO_(0}*7<>S>dIZ*H$S|`b$MYg1ux%d0o+d4tVP)Hx0e!N7*^3}(7FOYRyxFZ?qI=y zaGxFnPDfX1{mYq#mCSs$?()k++Z<-nZhhFNntdqwsHcK-TmQq3`SwsHOz+Z=Wkta1 zx8pCAz+&a(?1c8lN7-?*s?EumB0e$d9-ri}sMIPpiUe^gKNi5L{1NY4pGJly2$9le z;qq?T)YdsIJKp=rH(439+Jr(g_KMZEugV3zOF3X~^Ab8V$sP=DmFIj^%{+;=lqasCg-UTx)=VKFo=rDL zfXvUTlRF8_{7{0zhvH3`am9CZbnL93&yth%FiTIA-gfxvHuWY!?PrjEW46^PPt#q< zV(S6WQD0v{f)go%(i)TWtt+yOFEH);*x2{3y7(`)8P?4^Um z!II%&tC2dbCU)Wc_Rgc_gL~O$^Ku4~+t(%=93bkLN@ac99@Fy{VWIfkq`E*XcY>u! zF@R-jHvdP`q1)QsRcEi4fN%9kNFQE%;v+bchN5vQg^g~!_#MYjJi94+4Reg402vkO z1mDsWPHe};ap%{{Ceh0-k951OjXF-Wk4a6I*bLk$jy{PK1_F7`c5ip<`KRKaDP8{I z)u+TTUUL=@IO!#bAOA=_(%I|V%$e@*yPp@fv6k}OI_6?TU3i(sn_n19Z&@GmP41n- zQY1R*9k{`EkHzH`%hNp1wurw~HVt2H_tt4Vl=Nofo15AgV}ml=5_vfM6P8_b7)=jr%cLm z!k<5>)ZSFL#$>thzn{d88a^0dNj>Vs)*;lLUvf2VOv>{K+^;C3pk-YCZqgRNZP;{B z#>t1vsz(`v7!XrO0Rz$I=wSEfn>~_Fh~1snF$>3t)cM+h`7>2W>Fcst%RGZTJMa+` z%i0!wF{zYIH0aEuH+J(^=dW1to4t51zR;7=ms53={$n>1$S=~cVycI|CfN4~+`W;L z?+jJ}sM#$gn`<0Smol5LL2mxYEGD775pT0ilV!jfg+nED+zp}i!x*4&DlQ-5XWl1G zaWPnC4^23J0!azuSRZRIo+JGGZWr73_HPP;I|d47 zjOxBlxyqHFzYb-gBF8z^sZ@c^FyqSB^;G(&9Y{_Ni-sB)WDe%TeF-Mn^h66!LM|1zaXO(nsXA9XLOIdvtr*GZ^6@5xcniO4x0GBL z25=XatsiW6b&`-U0&Q+2V5Fn$dvIh!D6>(KpNm;vzS~|F=9>T`1h{4(rzHKjHMld6 zQe(V*jLk@P)}L|L{q)5#3gAh|%gU=bBz_(u9-HAE=)h9(7kOEH4*i69x{y)5y!!J7 z^&!nTl`4YWjjBG~?L0eocw6#fuugZV zBtu_ox}~#C_RdreaX#O-&d6Qs*6EIkR=X zsa%Hq{D`V5ZyqC!6)(Q2=kiL$VPF7{`xlN8D{1S$kkeu|Gbab1j1Zs>O51IILij1G zL-<=|V)X_;@1G{{iQm7!4p%7gd%V?T<7RC{JNn21Grc^g_n5pq#*)mCFLaB!xACo+ zA76$>mi}CUmM(ia18mlAl;WsUctgvYaIb;CCUPrLK26J{k+9y&2qmjJnB;}-TTD9W zIeWh(#E!?-swx~SF_<03b^p`+!;Q8vCm?YY(pRu2$pj>r7Y7CVRU0z>Vdi^{IJ4+#@1n6!X z*|8<<;|V7ropYT1epeLz@a@+L6uq)^+^Y(T_-nxC><-EyXV`I<4cQa-$d&X{URGHN zEr)%OQ*ES0hl)C;PEYG54L$yPI^$v&Y&|rQ91|sfY<+_3Wx0!8LV1TQ1$lz{kY2Hv z#OqY$bw0bEca?DJx*mxo?n@OLPnO!X?%|+>u$eEJ+waO80wMyWn5($+m499){*4We zg4~x^pWL4@ou9K_BTW)|_E&;}k%yicmo1Bye#)n0Td9L0zm>ai(eKHij+-Nb&mJ zzc9SEcd4+1@Co9t%I>3A9C8XSk@Ql%g31F(wQBx|$fQ?uZsQl-V8i5I3CPbRweULO zg5&?D_i2Rru8yt}ZegJI)Y(X3D&{53aAKkPy=XQYfwW=4!IYm~hhmE?d69u8#35tm zx;&09^+$uL0jg27f%3wQ`XK8_^PeXf+Q*4WNKz=g2!DZT*Jpl#Y484j!?dHSa*=N? zwq=r!RQh{-J3z`+>Q{OWs3O)dA$)IR;6Ne-&wwZ5cT(P8-ieHe<7wKbY&J{Sg9Fh(qefGLa*B=rnBq zt;v0*_nBc}VSJEtoXURTY(ht8qw%w`?HeoSR-_!x|J;8}ido~5hIWC}je?4B(=3^V z3UiCGZ^={=iiVo3kcfg~=PqGZRSogmcyMiQe$cr_&a;x?Kp-BMLV%$&WB>PEI5HIbuL-t^vt zk%Y&5S_*Vm{dD5zwx5!FznU zz`Jis{s-^s$^8xQh9Sdf%l3#`hIUuV@Lj_vHCWfng=PbLc4*u{Uzobc3FonRo)45l zdXY?4l1AZFJiKk3AvB7kZ#GAr9J%-e1Rno9fTy=EWuIsA3SCz|?8*CYGzCqSxR*F3 z`O{zc66p?hP>%v{vxc4g2|KM>W-P<6@NSc%+PiDCOY8161yb=yk5+y80BEDD#Q)$k z@KxZ#qYzl-l9i)GD_1*N=~& zZkzYR@o%lHYR~b}XxYUqnTwDn4X52)7BF7ya%_6s%w${sUtmb-PTfAqO#4VqMW{zN z%g?w8;JDQeX=+Akp4C9{yyn_7tmFf$)#;{KBoE|MWFph4|DX2WJetb&jT^7iks>rG znd?ZVP!X9!Qkmz>m9Wh-88($f36&`_Pnm^nZ}YB{d7f=!WVX#SoA=suI^Xkqe}BJ! zoz_|FtW`YEbKm!MU*qTcT+R%+m#R0v>fn+O<%{XIyOt&Cg7XadO+Ei&YzJ!XTb4@A zEAFhC#|J+LYcGuz2Cw0#D_fFTF3GA_v&NRvgm3#8W)H+Ixo}AjGfOnJwzfjg$_n5R zx!5cZ`k3r)M)RS~HUEsraxs5nFHTEnwbFD%dF=~)3*&^5x!F(dK&!(h3)dqcE+t0V zVQ=1?GgWqUMA`pE|Et!UQTS$*KND&+Q0!?P`51jd8L>Ji;BBamT+g%*dQr^wwHugo z%kW`=Fk;`&RcV;OZb-hz>SQWq1n-&@-Is2yg^zkJ3xV_h7kR z>)kgh$4;zJ5gPBjC_j&%yG+(Fuy03YF(2Pf&(z_&Obh>hoCYzw4Rqdi&`bs(k`RY^ zWoX|Kwi~`F)HL&RrxM7WL-#0ViwP@%nf28qmKj^9`Hm#OkzLF zh&47MH-WGtqANSvLllZ@#BuB(aR80_APDV}(3=C5HS^}Uryif7HHXO4Jq^Il5@)Lo zrBsa!P54L1Um8IFq~O5gMEJ0rvQ-=)I{w-XfJz!Sw5~uFpV*|f+X01so_n9-wyhwy zORV1Umd<~5Xc7yDdisWi>tYR?!>{_;5nUAG$MA3~l<$aL%rxd|#C>GfiL`mtbcyO@ z?kgh`>0<`#k0w=G8eRr*?S=Y6D1*TWVG31~&_Fa@s>i6K0MYL|44qrxOTml#AXx_I z4s=FP+hnAsc7%%sEj~~(d;kGAF`M?9mJj;Rt~<}%GuEGc8Whw7WDn(#LxCM2fL{ch z=x^4pLAQt@G-E*?7Rr^e(60*d3v`2F?tl4X29-zTXlXt;$i|JI^di-d5+*^O2OUFG2FN#A%11*JP&9y$AU#fi=Mx~b8 zZ6e-CQqw&;R6$M=b%?B?QOsU3(&AA(cmDTlD4$yf(uvJ8hWpMeVpPgvJguIS(W>Se z|0jBOeq|CLlX1GT?8@OA4u0M4e+1Ea430tm_uQF2jLrTvkB1<+;2H{On#vjD`^=n^ z;C!g*Qb8j-P~E(fpZV8K+0kwC>p6XC@l3*84FY*Wqg3Oe2=C?U_W3e(W7Yt_gTFLs6cMiD@j2(9#y(iRYL*QT`g8&rgBbr3Z#F#4RP zj*PZgzI&c_$Kk7GnMJ>toutmGgua4V9Zj+a+_GEyK_-JvQ8Vsrk?QRi_%cUbhgi^6 z%7{2JID>0$1%zXxM?zd4mAN3rEa&OY)8^sI34ecRO9tMwTNPV}fcW%<)FBOJgtK+r z7n{uT=~qUT;sMU0nmP}E`lrtov)@?WIy7&$ugZKoCj>PI3kno@ZzcY73PJSSp5rBr z!j=Q4&PRU|K2X-@jGJd=;L~GzM9Fvc+Lf)pb(ND}Udl1mfw$ou1I)w>_5P-AxooHy zKR^4kl*Po$s55O%gl!l!VAl)3$0?NgEc=(V*wCFzt@fNQ<>eK|?7poLV!C91V|TO3 zHE#LY@m#q-N*Fq8miuOlp0CgCuImAW&EKyzExeliI)CMc+V0!hLys?knKqvFxzMmUkj|gxNp{&zOu4_|Y|7ma&%59SzVR!!h4TO2 zGGE)JJI1fh!VCtP$S6|_>E{MwA0j+gY_tj&Z~y+)`Q8&!Hjee5rtfpV{Vj-N$C>=z z{n^!6zbtZCFsk`+$%t^Spr8pm^ZWxQDThD*_9QRYwpXsNY3lZxH5(1i-v0Z$$Jk%# zQ7&ZL8`os5+Npl;^Ap=dMJZO9XcgEwSQI%v6#uhl+f5~!U;YvvJ zX+}P-@TL~T=)xnj`zX}y>aBwO<#RO63^(_+10|L*-jeMBnh7x_DCH`uJvC{BL%xwZ z1d||%GOe;K!P_8~V0rQZ52eR4=W6-eRvUC(KFQA55Y#K;k^8Cl#`rFm6la54=aVT6 zW@WzGfnPR1&rV&b%{%gQ3<=T>q;(+els{tyirp_)z@6+r5ypBWlS-bc&#p4;AVr3vW#j8FQKJVS+a8wjsFU#M-OM?%>Dyluy ztBjIQS6P1IxotAe+_J>o`>Tt2jNffzTT4;^RdJ3eiHTFf-`W=&>3iA#mPdx_Q#8pd zUk5Ln;NRU?ddgn+rP>!I_%+N$z=XZ(M_6#}92s7Ff2S_GQ{{ij+alAY;$NX#9sJH; z)G6HOHOKmUzl|on6eCAI%2O}YR(%=`K`lHVEimiMf2Kc_<9z-U3WmiK^N$e_GMJlA z*CA+~X)PgUOuP2|`tL_n6pbcP3>LpvgSn7>lR&aX820p%%HiyjSodARS&;wAX@xAi{gNwiVYp> zS!V1MP}vFge!N}(_SdSNKeP8@D|CqI#C_$QSi&n6`62i(g&iX#OQ_)=!eRS%vCr)_ zDhFz7e?RdLOiZ4C*FEw#q7Ib%n?8vb>}#*I4*SQDIs75b`_oS_sR2}|QjCz#-jl*k zZJVd&g;@S`rl-zAf{09g^>uJf2cPM$Z2mKSyovrl7K-gMyN=FKjy#>I{lJN(FYKd+ z0fGCD&gh>aJq-zgzfVJP=&i%MQ`oXi%mIhbuA{N9DY^G+AAjmDGSFQY-`|iwsA57h zD=LMym#)u=IY|9l!OEhF$vcXFw(Sp0l;c;a4|6sHb~pxS$&IRnQ@^sfHrNxj@_F(9 z^VanH#sk-jxq|FW6Z!Vltm0hPz`uSd(E4_!TG=tHr&!l5v&rWju`e6<`=Ny}P`jXX z&X>PUPDYUj6i}eW8TzG7lY10Z5KDvx`mgm>4@11p3CI3A3`z!L^Xar-`Sm@6F*Y%K zePo_0zF{%ASDx%Ax{~3SiNO$LBXx9)|94rAUB1G$@!ZRTIpo*2V%2t3Ci?be%%Ru) zecoG3uHVuiP46rTGan+vR_CHv!Z1+;_sYfNj6qSOH}-d|2#-*riZ6(3K1aVVlJP46 zj-SX8_V2BPIT*#c7{|Fyh4$%w93G0@iqPq4C`sr0?ddq^5o6Ei?5A$$=Tm-w@RL`> zJjUf)=G^cb@bz!K6?r%egduY%qk zvU!Rf)#-uoyjStg!EtWP)x}}6`d|6d+V{-eds;}{&KRk z--64+DciLv;v-PkWWP1+;kEQGacm`ijG&^b%y0ATR>`kYMr*xKCnqvXrl_{~82YvQ z>%2>IKa9JPuc^X+>sU##Gb~m@99H6H+IkDsLUQl4ZM+e&zm~vBdN1ceO3n+Nou%Fr zFETgc6@D-NUl5{wzwFf0(%KxC5|6d#7<=tKps&)3-Z%@bOZ>XQ6cys*jh38?-&o3}s zyF1q^`KmpU@$TG_){Jv;Q52Fg)Rs>ep8e69M0Q)2BVL+}^?Xv$ZjXN6CBB|VS3Nv- zhsxL7!#@yGlJJ;DBNsMmy7!RXk|v^VA#(PCt1r*Nf0@GP@CzagHjkQ)48S50s)+k^ zWE97U0O3c#WC)4FtI5ru`Kjs75(zJBfX5NAX2y_nGGphz{3oDo-$7JZNKID@9GGn| zX!!^rby?xrfa(bWxJwF*%RUZ>=eyuN0lbkNJUw|ZLgxW59RmCO6@EG8zYqIw$NrPC z|8C`f>f%2I`=3er&sP5DUHs>d{l9kf)5ap zO5>iwYtW~>)tBEiisZ2=7u^tDEOIY)cXM|;J-^H|p&zHb^GuwCd_#Or4W<0!A%+V% zap1re%$=K(>f$on9{%OZNrhf2*e4HpLl&2Km!xX)c9Q=f$dQtSq$#KCC0~6SS{Ih1 znl07w`PKPRF0G9DVJxR+nhHnZnE&~L40Uqyovyks9n&v5Gqtiby{DY$I*wRu`=$Gu z_oJnurwmY;1^|ZumS})7{sjPrDa`}gl=O6UWvasP6}dzzUJRj!8~v2|IS zXrJtWULNSNy$G#Q7cQh8JOzBqfad|CRX;yJXze4iE<80eYMV zP>XeMDskWzr~ya@Fborc7XWK-1QG?n*8uD#g5Rw5o2&_RMNTac8_hjwOI+r}T^Ex! zxdGN=(~~Dd5ZEW)xbmp_O;XYYJO>TTk2p+hw%reC>a5dU$9_yESH2^EQ45G2-~-0N zRF3ZMdw5}6*MEgDI?io5H=jhB;v6W$&kG3^0Uj9s>&QM3?)$H>$(Rdhn(F%drQ7pO zU%h*0yJju*DuGeSpktwDQ!d-0s!c`+n<<+?;u}&gs2S34gNZHtp*f~&BLDbtVR*Qf zy!>ltl0t!h1ytO+J#pH;iqQrgF}CkzRGcYRSLkzhJ+E@qj(HA91}kyiU8Kn=?9h9R zm%PhS;!AB;_n_$H%R`JlHM+`!8m?GNS-aV@D;FOCj014) zI2Bu~e{UB0s%fL1l)>=&)>i>u-6K3LFzn{xr0Cx?3Jvn?7OrH*S!S z>hSDTXvY^}bhb01zi0I^lH+5WT-{tJZkoP88q?v!KqCurmZ>2L^rCl1BrugtdRzDxM_(T z=efb$UTm+^Y)7%tnikH8eOggMcE7+PmmCC%**LYUM z!=?f0rZQ*S1o#q{mD%yhXbG>U;~gSNZkS7YN%vZYd8wC{yuM&VylzB1x%$+&RzrEN zVoIz?YV`h!0pX$T%8+@1l*MyGMrYl%sdlZo)dwq)scJW(k5ELS%-c(?+O&lEE&9`p zOL($yE*5QRzQPvmMQxJ>p89T->ZWRI<=*ZTXFJ`8{K|8JG}$7B`qo$%X#?yoa z4XwNNqP?fOG8?b0=+;GWbs0q~>}4kEG>hFI1Fzm4_gfFojBd}QTU(EQj4JkOQ&n@V zEA2~=@#hrZl&aHUzx?f%Z3ky4m&OrJ-Sm;gBdpO_RWx`y4FErC`l zn60o=1xQCC&d{<4Ac9yR=NSQd66j5y1E~V$8pog2Z%3+vVu7y%#?*+q%<6&C*tGQm z>KBEue;$;SwhHyiIk?H$x;f<~zOqf8(u#U_+GA<{qO&8QSwb^j zxVgH!;LRgM($dnVke9ApSpasB+}vCj_;lB$6gM{YxmQz+i$GekuYOB~ivZe(ga$bp_jxTRN%6es}8a(NR%Ob#@)gA>IY_Ggb12 zxq=`;t4%vg3WfOZ2BM`lgF6IL%5CR~`IZqjC4%ef*U@Q}+?TJin|ZP)dT&S%o~9uI z`a$e2%o;!?4&js$JOPoWKw+=iwDi1GW35I?%*?tq$qcS1Wo%GOEZtuwnUSU>vAoM) zNSO!A~DQk0Yg4ON!BE7*kouO4QOq^W3xl+=S`qwBO;Bm2K~E zNz67Vemtq;sQ0S4jd_lrGP;nhjOIpq(zn5vfqs6)$fmsHyxp=GzK*7(e)j1<4vzj%@En!@Nr`z|=R7cs^s8uK&rM>!4P}|E zbOKA*TC-qQ$P2W$SS!3AR2%Fmr(D7@Rpqrslesb$Hk}qht+SY{kZ+K2O8N812F+d4 z!z5v=Toc-#mg~gEt;^qK_Nd;uj%#|ET7q47uCmEfNgVLeGgSUM9uh zWPreS20pWpjxXW?!GQrE&I_gozE)I-m6cLPmn2Gv+;kj6d;c)tk#Jc)68rZJKErKb z57Ebn`UwEHj_M!1xQj!=ZjLyZbs>I5_L{P^~hTiic*!D{3FD}?S z*X+7?ta1Q=h3ylI#2|#+7p@X|&L{AJF3hpe8dDVZ`>k9yH^MjIUZR3nv}Iogy|hE5 zZ+X*}*QyBdCFbcS1{pcJf9JNDIwQ)~m)lGk$Ps}VKinTyYm<0av<**+FFXpCVGHPz z%=fH_9+76BNCPD@<@Wll&|)34^F$NniD>O<)-lD+|9NRRr4GhAde!dcaX6N#x04!+ zo0t!~BMa4k_8Lm5o%~D5Qm^^^Im7vSBQvEc?W$8=j4HMHQ=dmgn)>sc;}S0PA05oM zSI^pR!fsljo=92XP?JC(m9wwg^ua7>8>A#Jg3Tp(ifei~`%ToFz86tEF7kn$nAr`> zy2NV)JYJs>T67?|ymuUmH4lccOPOLR^v%>Z+hD`+bAo?JlR){lATg4)vy ztMl`=Fi!0f^>LiX=Uxdz>Luo9;8c06bZG5c;I8j&ZF~X(ny`4HW!_ShnIbNJa`sE# z9p>EpmwSoeo?83U&m@5R2S4J6pdQs+f19sTgPW}tcbx5W1=f(iA5~SwcJ-<%JnFH# z`<$q~_TD0%HSxK$%Fb9O5Hrs@=)AA~yY{2xcY6VnO+!O^n1VWTdU$-?1^P$usnb8E zr%PN1Y4;A^pW0|PDX9DJ`(ix$1_zp{RXnhe3Dhr)H;8wp)}tqDaN|;&s4*4f(C{;$HgPn9Dl@D{9)v083B~Y2%Y3qw1Ua$*XLkoXwlIYZ-2H z%rcX`YalE9DwKuH34JRP3@$Du=AmOBc23(I?2AH96y z_wj6N;a`9k+#(+Mh;h2$#@7LV`H$ft!NJ~r?lz8oJgkd^!$nNPu|j>E@T zFzAd{0nLyc5wTL^Mh0t=!?}|G1caJbVRFFr?f$H^o`IyZN}wW2-a` z8F0?|Z=Fu7lfSb(*;W7-j!n)dMN%bUS9gtBAxxWRCy9ff;?=uttB)VKlrW`Fq%7l! z=y@gi<4WETumu**$mb>9t-mY??QB$1?u1OSX*nD#30utl~t@Q zqX%|m21|=#7nXC{TqfU^tW}LjSdD4UdFxW{9!Tlia8>45Q_ZUMDm3YRn#iUUE$TKD zevSFH7whM;_GyVhyT$IQ*YJ9eo&Ga>GUUB$Y%;rTTr@pPH#05x5KCZ2L>nB_=@Cov z(+!NG+Fi;T{A9?T5va&M(%;nM@2skcc5Vwsm*r(!SogQ*$KNcaANxsNb^W2~-$o=< z+MMVi;HutcF5UJCD=jZ^FM3&(8T2NC8W;26d+FwOh`Ih5Z3L+HaU>${sg#309Y10o z`WVb)8xn4o)1+|mn@%X5I?X!T{rdh|oM(noRni5S8RW+ZrKe~^nS0Dv+Bi^7{V@yl zxk_6_AxM_(m`QYFjZp?4sI8La42sQs>6@mV$LbdQULKV|HGQp$CZnD$?kj%GE_!i9 zic8$X#?58yHF-neNpZGsSu3@^u{mu@WdJ>19+NZ{TXH3#9B`JybpFqpo}QL|u4lEY zLdI>aK-2Uj7p=t#8m2~MkbSD(K{~-nF5agTLOmt%jhp+1u9)9uzyrJ+oJzi^c|7ZO zb3=vKxALD`f}y%xy5rn}lZD1Y(NZPrp8X!I!rLxg$%X2@iRtg?T+F(>L_(^y<_P|Z zOuEr6z;MP|hi!{x{7ZOg^o4ee2$t7!DbHu-WgD`Szg5L1nJ@@OI z@!X*q%!2j0&f7mw`IK*cQeuLTgq5zpWgD6rfC9D?WZQmgOVIqI?8<>PSq>C|W!r;v z&i?GntxVUzPcAIfGKL7w+USZpUrd^2p7M^A%(lTiSHg~0jk(neUI~w?W}2bf5bO?Y z%**YGEj{KkpUY0(wk&dPepu$kOt}|t!%vMsA@V=fZR19VC63WVCS0&$R_BK_M+mLM zWo;E_#UtFNiSM^0T2o@|jYp!+i|ML!O&Vq`>1G5~C4JKqaZw;V_UQN)c>bWV1qLr@ zK2Rw~i##Ltk6?T57^AKG(~%4{!eHMNcf$<}E;~$1@2)1XANBp{^OJ(kMxU4>^S*wxPzo~yw+T)$KkSA zV}Jd8W=mim+G6&N(2qc$RcVZ=)VP5yLr<%OSHw}-IU1e$2?cCAL(#kYgF`ze952^~ zXIZCs%I-%&GNZ|$RDMtIhT-e$qp_KHI=ku;o0KZa@FxsL-#a?$eA4RQwKiM?KhB2f zv90RxdJxMid#g3fwDYbjUybupC+SM-4#!QElPpS>TP7PReA-dk|196fe?9FZCGlDV zQ%Uj~@SWTd&d=0%u%@#V$SQu;agnpCTH#RL7>A&x`)lM)%XkC-vWwl3?Yu3A3(CUG@kI#3LqhA9~((WYdfcN@#Pys}J?a#*9v%5I%bTob0@ zlN#qeCJ_OY&^c*K16{<+kdLI{bC8u0k|;_4pzAtq9L+7r?GuKnujAH1r4A6U}Zn* zBJS1(;MJzjI8xDK2xHPC#Zqb^B~=oW3~xR@!sPJc3c^>ysT!3)@R%L{49| z9r_Hjh0J96Ds&J55`OJbmHliyHO+~kqq(RrvVk=ED7}uMh}Dcav9m&aBj5F`v{*&A ziM#JtwsH9f`PaU$XP#T!GCFlERx@3~_h{Q-%iadbDXBTg-VwVRA0_KUCgV@B{e9Dh zp;Kzf9Y3P*!IME(S`Cp)Lj|#r*Y49pSVm!w<@Fjhub4914L5bmgm)_OY5h(=yiQHg z@T|A?N!zW(vdbOaEp`!VqWQ?AklTj>E-Yj2CEEDWJm1}BnaY3Mz%hrl*M5G@L@Uo~ zOqfQ~*pnVU483Xb50h!UGdj|HGpl5)mVM3?jF**nkcnz-b_4~Q587sMpi0=VG0mml z@|&>yW*Mvg)Vc^5^D5!BQ~Jw|xw0lW6PlXLT{iQxW-^Sg$@_yrgLu36tK>R7)%0%u zh+rVdvx)WXm`I!5h=(FsuX|btc@0ZCTrSm3mQjJa#~4>;@S(pYV>^#bH9ctm*0X0R zR_X4W%>Lg254X}IA!KMG%hXhfiWaYasUx?rV)VJ%zat(gwW1>ALSMlTw8g9LmnEM4 zs~`VZu{l@}{O-5|*QTcYGuEH-EdR*QR)p!PlPDX5c>Rj}39?xJ(cFVr&zLb5mJ+@+~C3wZu*RPS`@`XAF z*`fk>y-%{Ecd*(qX971D26sp-%8Ewa%rE+cd6>0jc1`_BJ^y&er}e4i(_{jf+nGxQ z`=+7~IeplvG`^5^yhBMtcjgD0O(`{N#`TGSGa9dGs^`@Dut{EavD+lCw9FPNWl5vz z3t#`^_0=!5t3it!Sd=56nl1B^hNC|ek=yPq``PzcI}Sql5H~B&{dxzEW9S?wl}t*5)9HR zzr5V1UrcmtCc4x14;3k9;UKGG?K#*}g;_BjZ=3D7V=6w~;XKnh@tREGPTw`u&GMo1 zgE56GL#|mNx@=hbGs8@mAG*BAVMjMMI1UKrT<9x43f zB|}O`Si3W7X-)!n>Sve6IIiww)6TI*!>|%N`PbQLS3N@$?M-eOV{S1|$CCQ%4zzPW zRxEVxBR)y;j(*21^JoX}4Uz1awx0+LXBJz)Ka$y#fNa7QOe2_OpT8rJE2xA(_Qat8 z*?a?nVQ^sTT2EKB6|L)TKEe;%L?m$VvtHY5M;FwYP+98()$5yqxm{;n#?rN_sUIr_ z?5aihPZ%xBZse-7X89AMP0}k=-?K@Y_e9 z9Sd%eQp>(%Go@X)fO5hb`sXt#-+5WGx`j&%Aa@tg^wLK5`VQzbGU5}&bR zBJC<&$@SP8Iju>uXre=9i=qFOSEpEK zZ#{<%#U8c`!kk{DVppz9YB8pLk zZG+`2k((Tg?uEMZ>_b{bo@qjgjBV(|RXElK@tUO zL+bZk>}OaVd)8{q1x_V#_}|u(Ma(Ug5a>kBa9yooi$!!Dgg{Cj`gVZ{BNbvfpjTf8 zqVGNRk*OY96fFMR8;JL=YRuBiFc>J^pu1*$)nnZjGxLQ-JO}u4h43fZj*yc>x#S*1 zzFg|5^M0`;JC(#3?f(5Cq7hBZmVJ4W)pLE+WzUy8RD1b<<6o!wZXpF7Cw!~vd(Ti0 zT`aCk#OuqfnA<>+(MCz;#e2}M>~mKGGpD#axMAy17#mxWsy)*Uav&2Aqax&u0 z{7}%jqD#Qig>&IlC7!mt3)TN*Iw~&pWKSPxQ%Z>zN8;MrrlF%3clyBLxMsa@$h2BR z83zLV=RQ7+*r+(-fn0Q`qWyd0E@z0>2Ae^p33!c5;GGBHomZi<1r;j`S^c2+iWdP< z*X8yk?7UG5e74N5yZ~KfTVJr*R3!}CgpO_~<5VAmz+St+Tmy)W3le#5nVXwO0~3?c#3=SE;ZKs#ar3SB(aFutsIeG%(8k&bM5lV;}+H>rMe9>}~D6cszOZ7I-Jy~T zt@WI>*H z490^jj`Mz=rf-R2%f8T=)|-k1WT1!}wTw2_vn>g|1g%TQ6jsBKtZtBGPtdrc#8sWw zmiLsEH}amI{wD5j0)$LNxS;ZgQVLw=hTh&SNEU$5C_))iN?yJmbnY6ETSvWXqx-iv|+@vd2UeHcOgjO>9-inb2^7>;(j=;q& zb0)drri^%V0_v_DFxwR50L9hH0*Qa5<$&w9h z{qo`}6QA=8sHy@rLB15lXk4~~65#NH0e-8sK4LvttgFQFMsZ;f?InMla6`?e2kF`8Myqoi3Eqb!m;R$>JXjo!J zCAy{&ID~E2=DXdmN_o{+aqi{F=W`<`gUu8o`H96TXX+cIK8GjcZfU`7hH>S<&0R3v zs(i{Ce&43_O7}I%8<1>j`By~IDC?RQUg z(DOq5dWG^cTG0sS&1EyFQ$@exF2EMK;4lQJw-(<0evl(X_UDzMa9}W z(N5lyHlD!F@1ZAr2A5jnUzw^XNj=wWm!F}Tp`UbHg%E7Ua_jX3XepE9HxBt6ri$0p z26q5P!)9RDd!g1a0HhK?<4`vLm7L{PV4v5}4XKB!4^^-JB_3b-Z2-CJO?5Y%hwU!l?lO&@Z>d#>#q{L-i39aW;|6!5`50 zkIl-N=J5d^wpZ0-#H2aWeN5c9)tBlQYWNEpV4?(^*J`u(r6-4|>F!+zRz0BnhJv*_ zD0#pXbY*I{fzpDR0+0)VDSn!Qey_`syprxa&;7h4)@FOV%8{fXHo3)eFo{OEz%cOoPb`) zKFQ?J0eb4ljC|0>qOOZS8>36CdeIQ=2m1TBSwCRCTKgzI&B}S9cBQ*y?Om|ifuW;+ zaR_bH_bhoEFwfxe{kbCqxuvy<79-$E-Hh0|-j@&9?=Omix(GE>p0gr$DUqg~A_G!4 zS%Be7-CcNe{t*r|Zij3aU_UG{2f%VO$fz7Xc6J_SSD9{#mJ~_li#nZg;e3OXWXs zzuM5>Uj_^xIHv;ukN9>oKJB9|4Ufd~#WM#UpS+`}2x9_UJ^*2iDnA!EOJE8(-~vX881{=~ctuM+dCSc? z^FvSA{Oh!As&}RRK$~{y0Z2kn!HfNy{54g?fk|lSJYRoWX9mcShvuG)3IS6=tjE8= z@K3mclch==1SjnuJ)9B4Ki2%e5cHozYgFTryB2!UCtAb?%FEUsc}OKVXW9LK0r`h) zjq}tIB}(5&R|AesoYqcvc~nJ<5g<78E;_$^sSYyy3z;uV93~Gt3$+{kt7SO85MZ9S zCf4Fq#+4BVvgO0b@TxZ-jy_c}kMFs(HdlRX#KyB+X!dKsxAi~qm9)3xAEX4*N)*&t zAlVdSvJ69k`Gko0C)Sl=Qa5;h=>YG6I(9*Z6Vy}hLJgTn@yO^z>2r9f_EB21dP!rE zF)x1!Z@??2l9|R)zng^B$-Bg-o)t@b{L+4et+6@hF0^tQn}2GCD}*89gIY%*#~lzD z*g1PZ=?Jg{fi)V1Lh~tR<{VECpv>yaEQM*yK+{d6o&f}a^+0J_e*>A%SOTo+epKce z9Ik(Dle-v?2tF!k6@8|i*Ss%JgpK# AwHMXHGR(Qsx!`tAXvYKhPwVX?1YzqV`O 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 literal 0 HcmV?d00001 diff --git a/file/chat_room_menu.png b/file/chat_room_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..13d0339d4237a6a04742e846d11dcd8c342ee2f5 GIT binary patch literal 30902 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 F{{X8aDU<*J literal 0 HcmV?d00001 diff --git a/file/chating_room.png b/file/chating_room.png new file mode 100644 index 0000000000000000000000000000000000000000..c5f222282627a81fb890df82f0ee8293027932ca GIT binary patch literal 48835 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 literal 0 HcmV?d00001 diff --git a/file/erd.png b/file/erd.png new file mode 100644 index 0000000000000000000000000000000000000000..f20141bde3dfce547f3c4551176249b03fddb243 GIT binary patch literal 43142 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
    AAG#>;}0Ge_ZIA+5AhO9PF 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�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 literal 0 HcmV?d00001 diff --git a/file/login.png b/file/login.png new file mode 100644 index 0000000000000000000000000000000000000000..ff6785a92659c7f30a66b817423597816c2bf7e2 GIT binary patch literal 9862 zcmeHNc~n#9wvVDxMe%yAVv(WMs#Qd+7?3fjRkSKNVAMp2ttEjV1epSc5D}@hUP`Ti z4PlTnsqp}o3^E3#fC2#p5+o1?k;#yRDTyQ`=N&jyZSQ^S-gWO<@4fqO|H(>n&i8%$ z?BTb6d+&4gu$$|WMaGK|2*i^8`#w90Ka5Oe+Bn+yGOaA)lT1Y-T6{h#gh2=|hT z8{<6(s9onlgU*PdxQxw<*3vFTBfMDG)-dz#E;yfcoa}$N!VU4!dmpa&Q_CR((!5m# zi#?Vt`QZFK2cMqg=#(8tytSJifAZ7frk|=t_pPa-_mG~XIS0$99t1ZOk!2%Asyaf0 zO7YO(>+t@D)B~9Ck^Z9HuMAY?kqZzmjPj?vc?g%aEMtUA$sZs8UOK4hC;<@{cWSMd z&-Fnn6VDFH$~0tIr$s{5IDdRt0XeF6^~%^|ssEXMCN7AvPZ_3Dq>jG&)Pn_Tlk1te`VVXOHDA z+sn+hDa#C#>$I{XU*YlNC%)qkiP^fMMHab&u}!?Sn^VzI+6TJ5-ST$G6H~Q zF3dh8RaIz$6fb{VU+;#obIg@rR=S@!X$d5?Oyr1!%7%e-TBlR=Zx^jY`m-6>c5!b_ zeSH$vZWr5l4hwU%FR+L@Aj`Go78By>bW3i~Ww*}x0^+@!=mVcK>Gb1n%w@Zo>#_y) z^_y}9-((1WNal#*+(}`LX4cl&fWG$x#CcDN`+bqtg|)uOFMZfqtYrGBK9=#P(B2q+ z{kw%R9B-(~-!TC-ztiC|i!XQ+p;fV>fxH7v*bC6V714u}n~z>pmsv zO3!*O2N~c z(K@tKD`di0Q1|+T1~+jHr>!L5c(xiB03U|VnK2hJL-2Wc;;T(nLT$w(&+^F0x*MUW zttQEVi`i5HA0sJ{4@_L7I#$V%=~- zoKQ$q>10+p=GqwERPI?46kGNn0hbyzXr+^Nuf_0(?;V`+HvyhW{o?1`vWL3$ic(+}A z{^e6IA>b)3(To;tNOL3unrG3LqbYd20=C|Ku4pGoAso6pR%zulE~>8~8*xN_jZVrc zD=9^(h+#tJ*#%q8cqdaDD+(r=O%3u1OM?$F>8BpUCM56#pQo5ozKVDrRScyTgW2fT zqTBuQV^p^6WIA#O?8zD+?17z1P^wSF$A`la@*3?>x<4!{0lkMQRTs|3EBC>PYY_w^ zO&M&5SK;ec59x^1a$4RZWTHEvGN6 zKC`Fx`ccEApwfihITYEw;9vD-Q8QSj+Cn<#)zw)f_0dD$Hrd#BW@F6(yRT}GG&@m+ zHn7eEncA`1E4g{ZghhVrv6A*UzC>$;C~)q3zVHG2%>}ZRXIp2Hm^KF? zIrd#@(Cd)K%;WAeSg=A!l%Ia^@Up?AgNjnb$XxX|kER??qUj#GhHWVi)lHgvrc4b-jnrbx2;n<4 zjS-`XMh&<@+WUKV&K!J4h>_mn7Ppr49F2NjcWpip*5ir_*Tqs@GptYUqx1w#bYGPj zy<$Y@=vHE?uB_f(Sx-sb{`zsowMsxpX*gBqJP^oC#5J}NR_nm|$+?16OMoPguhH!W zcPcj}XFZRSOc?meeM?epBhOdgA4sPLf|Gj3N3ee3 z^QHXxC{v$ASQQH~!0I&=?zNnq&7?aIH?J-Y|7vnTF4n_F;(*>PG$Tsn%54=rglQaE58h%MA^b6L#|JBV}ipVqAaYN|Rss zyTrMs7j(^2ZI|o>&ld{EUQ(7i6gMlMKJ@Uam^2`zqArX@05&6HU#&%;&Fo$nrllkS z2;eD)xhb=DmeO{K& z8E8S3%$G+uP}zlFcYf~_$=<*c{Y28l6z+n3U;)f8qhoI%Gr$89-+Ga>MW&s&IM#_r`>=qmoj zV3WPt>)b`mR-+0Ac<%m(hnDGS=rV#a4_bYS;h?D6-!wHqS6f@ug z3`_-R3tZm|%FR@7mmh<=6aFgLWiN%I{JgY&9(v*743iymYz1L#<0DXnLrA2ovoKF; zZoazU-tlP^J7$<7`+zlz@t3gvD*SA9GEnABWTB3K3Bpu72II+&!(tX0j%LnYeVCIGB#Vt}~ZYzv|v^ z22qGibDpx};nI?LM-KmHdIxQnbshl;rbS2xujBE#KhBf} zn~p)|0cJ_`c@{>#zAZW$E$Q+McIDUKh11Qq0qlScmPEE&xTO!hi?`H@#zw`1MI?aP2XKwlFGD^{g*;Z=zOopxYTL7yh}bS;`r>Oj$-|@d_*J(X)kN2s2dfYk7{HEn^VrX>2{@VKWRUbz+C)O)(>X3rAi3(QawRm#xDq*EBQ_IsLr&^BlG-F(PCjj7s6pHiGl1(-PpDnn$@WG@ zr}d39VH$B3;81%=xCmXW`TJJo>YgX9@~GEKt>m7Ox>#ORA0WMvkBiWDsMfcF1Cs`} z_;Oi_1WQTSxx=8-D!wB1y3a2{!@QWc*EDnPN_kp0s&Fewue*-O0`zCWj%AlphM zB>wic1J-~|q_#8G+!QHPHP8Y_LI*s%!b3SvqRzJvgY)z(-yBaV2p-yiO56U_!V2si zrra9OBbDkZpHMQpG^6fY>PuVgcd$c`_LV0LI ziQ)EraN4fM_`eV;g+OQ3{8lbUUw5#gL!t_EHZ5IlBUh&a(Ws{d-S$sc!g6EIAukG^ z{j-_V7x^mz{aeQ0bWhM(6!>K4f_WA-4Gx&N`w(S?a&5`YymRsn84qz$v9ZR@F0y#w z+1=Ti$B)fywSm%`3Wumr|5fVz9U<=<8A@|y!f*y16}EvZj0z3S1dS2GKl18q`}1E; zfPcHI`ggaD&k}TogAdbbN-kOs`*As)g6`l;zT>tBhSWtkcK8&&HY*8$1+~>Kr!B4hF%?}(t1rd zA*#i6eG|t-bg#=hg2XNGV2L{8ihEB)DJQ2J5r%Jz&uqbaNLA_~pxQVT^vBM!SWMjE zby;`ScUp;^dfp4)6?+$uAt-;*xH9mEOd3iEci|GyYg?vNE(BSe<<<@&MD_KH!JlV2 z??#|Hb8G?y21wFLihQe}yHoapm=DLtE*H%IhT${q92<3jy*{kK8iM4U$lx!n!J zWE5?5d%R=#SPwczPfVlE8VM?9=CO>gg}$kzvjwZ7%g~I%KAp&BZ=NQ-LCia<6N>gQ3RSx{z(oqPSbbC}UDuhrg zF)SI5{YO~;fguuvb5~K5J*hF*%Fq_Rp(6!rn=fN>iq-K$IQr!SswZT9EKph+bA^w7 z_Gq7a@JhOg<7AwzVZIXOJs5k4T*}0IRhGx#?(2e+EMj;e;(_gjf>{S>CsH1s|K2B+ zmKKZy(zj_X^c{p=sX0SbVP_2C&jc$VXXzkERNCtJ{{AcN%4Km4(jWHp4p zaoeLcsz-s!Ry{K0)}n)P{Bx5O4`es!HA`a)$gUwCByu=t?} z?D6t2t*)7RFjVepH`)0>YraK&uh*eSVCeM~>IY@3KReS^_o7?+cwLlz?&~PF*23C$ z${w&8zY2Bb`(W9en$Qdy&fm&PePX@kHdKK6dPvA_$#a6DWWXh2Ly=)r}F34ax`kcuoUs&ox+p!Xwb-Cmh7~Dgj=N5BRg9JOs>X8SyjwHcJ~? zLR`_6FQhhPD$;{>&``&Iwh8FZK5K~_U*L`UQE8==QA~LUwzJNrvtMfj>m6P<-Qb?S zl15V^%}EdEUk?d43Iwg>KcD}uh2HS9p@j}``uvTXz zr4BX3vC5W|_up7EG#K|dmc|oJDX>6#2qmA;5NwEV>)!M0ymC{iU_q-v&ivK4^M=0D zC5iLKUPm|D^Nmb9Qubk3&5^Y~dm;zy+@~feB&u2tj&JDF^bMrabb~dmpp+Ys!h1$7 zrSlmx8I<@LzzNh#4^6hRe_(f=<&!&^u>7naEjVrb7xympkbfPW5nW#UC2TwtWDT zx_^>;{3lFQpt1J5LvSK;?r&$=R{&)jAw4wS+}hf~w{N6nW{{jqCb8qX z&IS1Ulb3!B$y6TXk)#)_wq+LGnVzy^CQW-LQlAFN61vvw`fVAuqyRCG29kLiPgUeL z>$>8gkCS5Vo5c5hzj^u{g<3;_s=GY zyW-AZ;;2`u&G1TL@Ht+P7-%nZscHWdvpk;+E$hAAjiJ|BxSm<~_QM XxHTP@=Rxn6A@=Wa`z&wQnM?l#R-)kpvJ-CczZZ`L2owoFz1=_~l2=Ybhs zR$I?_xJ{rB?}mFmq;IUFBk~Ks?E6z$+nO1$r$gEL>H6JwyJ9D9<>xm^?in2y<0n%X zB<-tV;bGsrGJC5r)V+=R&Atl~PL^tkjKcxBir;f;Kvrq8ul#pMP4H(+r}D;&XW`1= zi!gKhefK(*l>lIUhU1|l=gjTF_rLhUZ+`X1|Lmdk!yiV*m`tYn?12FWxpaP3HODuz z>;yh8r@bk3YOF~X&%!$3i~Va2t7v!%@+CqI6nr>ZGJ{nGH7)_*RmL>WP*;x{33*x3Wp4(`#@q%vhAq8rBOlpAzuNNUqE~|a8+oQQ6 zI`Rs+NbE(cI4a#gl;89f=UwYnrpRqn9Gi@>lxazm_iiSw2N(Xc2*wXa8TTyIWE~)s z&P^xGxLpBB4FnJ~o>?9JI_FKGFtcM@?1l zc1KQh-!?Rf!;L>*$Z-&~N4C6X4;L9yy!DvE&}5fPQPaD!BJDtSoN&rix2p2GV}aol z80ap$tVn1wZ|Zp%h95I>u`EZ!mGdLk-JCvWwm9t&Xb+CWMH4^PHw(){l8Yage?sSZsdE4CM5c{5aT%JfuI25*#g=`|K~6 zLStEEu0?eFkIyE}JYa5zcq@4!b$a1{a3Q`c?fs*qLRu zyo4*PQ@(!9HF-9nLSc7zPH1TT=(dw*II@b)?TLAT#MXL2J7>k@i=0i@r1f?6fw_hq z7mXGTZv?^GtK3-#yi6oy9a zi92CV_b;k_B6z-#@gU(grBQ31qv2QNk(Y?oH#SL}Z|ZIw6phle;I&1;Bt}iKU@S*3 zlU9yLA8@XMb!6uV9fL%JGON-U$1?-J*HFAqh#%u#TsqO*JtyY-i;M$^lHU~3hc$s5 za4axio~*xcX7;<%Be8mcvy}yrPK0vH$Kl2&sw}+%F4_fF)c1@q8T_3(j^RDZC2u<{ z=uJE-EI)T)pk*T2p zcTAd2boFv_^LNX=5g&=6FkpdMO*Ne{m1A+XdPhmKU3QG9$}YQeX?AYo-;pBzDWrr+ z+XINscRoXG+?-x~I}KSCBb~c!IF@7U92wB;++na8msM&PHCC@}5E0lv4-0%|lod$K za46)(AkU_Hy{yMR zj3gwEPHtk(>GU;?gK~C`w22Ns_ZqRelE;|eYTG6$`G9FM8sQI(R5sC0n3A|7lPBQ; zX^VU&^AYZ-=?+6lVOIvK!-4$3_i8sSx_9hivHCLIINS*BD^hdU)5y??(MGa4k2`pg z(^D~xMR@3NWi#6N$@7u&N*s0aueT1a%{YcTZbsc4cFHO zs}k_O;}5%X7-t2?PLLqx5fqFD&ToYbhUwZDVCDb|oqYPxa+@U7VSRu`?+8Zqb8= z*`q06ddJ$)v!%nh*}fuN_=7vL$EpTM@7TU6nqiMydT79y;aSSHsc<4`;UBhor{H>j zK(yN|=zft+&Xcjv?y#?XKk1)1ly4ItyL!D``>ZUu=2RkC%zxKP2#A}l9!P1gGr% zL*swRlab}s!msbG{-(v#e?_bwEpvL#wo|IjCKw#%y!2v+cG9=j{O35)xqbI>po*nA$c$DXq!^wE6y&AIoB_UScALbF7a z@uZ9**#2+Hi}ZLEFmDp(*_qOJV8H)9dDCy(PeP-yB=oq~3XM#!Ee4w_dViwjfd!qf zMwfEdn&w3VHs>fOZJ?N|b=Vo7r57g53fyR=!SIR`v^xcf`x(0X*WH4=G8IfdYUWzi z#aHHk9W@QaXQ0bBrT((6=hN$;q5$Fz>TQUlQkWKt1D0Pom8ao1+tL@G{-@=(9ItDY zW}Gv#phSL6i)_VoE0>VLIy`i-qAVFD-TiKdB3=DgwDPwq>a8na29hCy^-e+SOpeE- z*dOlDWXBYCC0u+Yx3+pC{e#9hZ>wh>NujsWw2(%$E|2!fj63Cr@h(UFAw*|MXctjC zn4myY)ZG*r0bf{HI55yy*dmM$AReJW_G5=3l8yDwf8FEfnrlJDX%a4ns1$mUQ~%|*NiOF7Ab7}mq zxu%^duoPV)=ZW%Qtjt3#ev4=@P$aaZvnsilV$RyZE}}ez1&aUio0snJl>_ProHs{g z+--w!V>pn|7|kCrn8xMZgA5oo_QzLFjI)}apYu`yYmyC#Fjyj~I2EKzeBr%4qX54< zn3b~{^9kCX)mWnS;o)h0kB0)s0eH<%13-fkMQpcEsjKG6m89MX3tJ;NP39 z>mJMI_0}I5m2HyuJ-#V(`a5+G{+pqeF^Y%^KRd?l%;Ggu8o?-!!m)*SOI{CMS7fLj zlK^77Ti5p3Vt(yR^mobbPm`BnEQ98P$^XtU&^mGeKPgEG2OsUL6x3(~F2|*;# z0gLy&Lu^itg1o0y&UZk-S-kY(gPoLyaH5wy-DX0w3t(CdiGDhbGiNWU#bAgxKJgU* z&+Sp`E`5R>9;5T(f{GjqB7zpS`5Nb2^SkU|4eUs%8=Py`;>H&w_rvAy}bcXDE{sQp&6*0p1ZI#%rY-lCb_p2yOtTd9l&%Kijs|2DpW zaCgCnC*3NkFacF|v$zS9!#Xaqsr{)W*wr8DGYZlYiPBC;cPHx?G$5F>zcKic(U`*F z99#1Kk$y$3vtE$GQ^`sS1I)`c+Y)83&;F=_vDsB+}JLogp!3}BwX zsH8Rix0+k|PHZoZvWGOu-@Kd>-MM`;8a_NH>;LKuIpu8LwI2!$JZ536Dj4`?Z4)EGk zYW2Oj?7j0LQR3%JjaMU>-BijdPb?45YS|cKTq368A05ZsF>3b1Oup|ab1V>9(W#Tk zOxJnqUp=&|JKX-HzgaBw#DVY%Y1a#(zq&dVJmw6EerYtCSr!V4VCRUb$JW5 z;r4vmUvlL)o6}E<9yj_VYmAr%`omY|)*zVt*DqX*b5NCCB~N$Way(RXW6@K=-MFQU zJXAu1bp$F0dUm>%n_w}9-2w1`$Tg_McbF{6F$QbxMSUvm8YR*%IKL;w&GXyN3EnrP zyO%aXp{Cby3by5Jaf_K-c}i3;=+S&!N(aNg1~uAJg4_}Kux8)u@GhxU1v?NoW_Fxv zb3E(jUkvb?7hQR@^&gBagS6ZI)>W_2L$ll4edb4xIS-8)|Fw|rf@4AJM*KYnEaD|% zb1~%LyTMo^cW@K+u0Br@*C4H`D~vf)nR9|b4HT@7tUOw}<1-RB9D{^KFhBQ2e}87r zRNlDi@M1{&8?>_jo5|?kj=(Cg;VmksS@G&dwI^?)(%PW5Wls=>n1VbJHBkNH{rf#< zppM{nr_Q{C87L)o5{ZTt-1Xp6cns_z>cpcZlJn>Mw1O^rP|qqp=HCh z;+zHnJ9^f^bb?YfW4g_k9d}54Tce4w8adZDZNZklKito&wb0Oynw8X()5$xJNJiS; zJRh%bPoR)9^W&`I1|_Eci4xIFRu{}pULleoU+F`Xn(Zi*O$ns(?2maZ1J0sZOyv?^c2NkZqOCwyo1$ zBa&L4fdn6eo8f6((EzFOE9>0+e32X-r2}NJ0$w7KWgoClA#T!V39QKBT+bZJ@mv z7g73ThThCiwj=cASr;FlN=j+cBc)sN`9nNPT=2B;{rh`lW2E@8_*H+iX(KM@33fsv zjtBQ)mo_cNGW8y1H`l5K?pzXza`37)LO^>m^-7#@$<1}5QZf^{$shHH4rjLSkzgIi z|F2oBO2>jwlr4VlLxY1Z+dZgqU?j~d&as6H?Y+Jx-TiY71Qu9h{cr4t{hu}&fjSoS zW^HZu9c0LdYAIo7*_0GK+1i)|6@|M?U~G>&f24QYNoGsfalXCD!;;k0qeGKvDc5g8 zb;hbrgitv1=EaLQ)(!q-Q`)v|TX%+vf@inh`0@WTT$l!qW$c^{9Ij)l8m3e9aUW{? zu@#b6_v=QJum)CBgYzkNJME5kud&%t6f_^qtb$$$3W`#HY_>mLPPNAH{1#7qLQ3@_>r0nu;Uum}xC#p>4 zlJb!c6e0nyUfDAo%awIRJ`AgAg zU-vBBL92p%x6R?N$fV0JwWf8Us*+3GVg8}s<8c7Wqmow2zRz(^ajufuPx}AZFHPQ7 zGZ1kMb5T*7ADKdlyV`L|!q~JW%K_k^gwhd&u%qy@#Ia%}4pJg%sptnmCjJ+9>ZR||dPhOU{`JfG>_Y~R&a4W%x#aaD#D)7`=plMVx9MV6d^ zEEwG;`qiu5b{@Dn z7hY;T8TE(A^$e$bo~IP7PU2<{q5gP?n`{&q+QiJAsR;>o+81|yF{@k*BpMl^@Im5Q ziW{O%J#`*+T==Jm&yK~?7J6QFr6KAB6;UIVbpn1BOv@oZ^WMHz!T$Y<(2E66S=~~S z8U*GQh1{5i+FNn@RfzLb5Uy7p54ET*H3k3X7a0D3uloMeruN^e-TZgG8na|sO9(6> zu!O)80{>0~&|F;U(-Cpy^IPRPBV^Yds~`GFB7nPKZ{*|M%lIl6&b$d*%EHQ?hD>z4 zHq~;&N1U>L%yF0SNA^9=_Pcs1Pqq_S4<34byp{RE&}~9%mP0L}ccZpRqaC{h+N;Tr9)YguOxFkPULXn|*iq-4(Gi|*s?sBH0p<0?vMuy?V@zrMw< z0yV6QdVWmBmJ|)M&$vTKxR5519Q!Ns)~7zX+jUwC*Az#gdr?r$7s|B&vGgaRZfvc9#`=Eh*`vOzX3NLB4Qv5pzx%f3hU(ldURb{Z|ks-!`mglJSM-w3N|bL3%}>&M0^BzW)J! z)*jgV8-&!}RQrNvuL>{Emz`n_RiNgzV*ZCdOynvJAZ3FJ`cJUwD!){WZi{+51q44@ z58PMS*Zo25q=DYcRRhT@lyvkVd3C;@SZ824v0eqeM=>+3)IVyMSDA-2TRFb>6vX$M zX^`)rLuCm%f61~Y7&O-NC*w=OhxZ_r#~|(H&E7rwY~pm+n9VFL{qXeGxzNFONt`%86GKlL50_NP}iCke)xPt=^FK#WJ&- zpPqSsXL-|ei(2)eCyNKJ)tdepsXEKpE}HT8AV-Y%+3`UeCgnR{9KjxTlL))>POkR1 z4LO>1)CDttEbjU8UzK!_3RGMZN~0xw=i07z$v35=f-8q9X`K8R>O}Tqe6g5p9htL` zETM?El}N1sGw_Pm$F`H%O_hn^Z%7l7dHLNK+Dcbc_sE$r#^hSY2CA(Gr)z|)4u};# zc&P>GvBZG^hZj3QrbW^ut(S^i>@cmLJl1a8FN zz!dt#4<3_Ce^9Y5PUTE{*L4^rKjyK%jYoF*jrm4b>~)oRJa}d)4?*$0+cZhs)lO06 z_v-8KypOq|o27J@^NIS?YYvhJ&8qZg7w)+BAXiquEr2lmZCGXj*Snj;5)-tZ!j_qB zS1fp1jH?sZ_EoPWhR5Mmr?1)kxdIShP-yWW8SB1W;C>a!ac}NfSCG6GumbNV$dAFR z&4y=n6ICa^p+7^CkIUBzQmkcG7E(g7pet3rD{eMjt5Sn3A!|Po-P6{6oFQ z`-=lU-+Nyvv2P1GefjjBkrx-XeW{wfXIK25mIm|}X^Fv{Cxxb$LLXPfc-#6(RQhG! zf!iHWE=o1-v!P~90Yu6x5Jv#i#6Q6%kxt!f3wy?DngQ4eTMjaRMeg}JLvD#j-#rcx zXGR(kCZ^`znWDA<0ZAZ>@*9LT?jnS6dT?q5_T}rz_H*EP07UB@7+Y8NOVVl{opUj- z^vJ_uGmzo|f0cL)sJca}4Uf)YsPlKO55yi{`!CDMUzm&UT)$-(7DdH%0tLnjf~U&h zd5wDwQ_5#Zu%_Flx&XBn3|VrE^vPYo6N`+!krg;?|Nbr?Kt&!PKbnYnAq-z-i|aJ9 zsN@cmyX*eTS|p~jgGF+{omX6YQ`g!!}y@o`K>9FfXt4#8M0=e6NBnX~A z2cE0$dQAztuYpyHp9Dkf&tMP7>}t{|C~mQNup7Oh9HaN8jr^f9s4)5G7ncg}fnBh) zRi5@0m#twGAZpnUB;zoiVwMclC*Qk}p_+fgU z&58H#K>Rr?{=)y;xymW0Aree* z^7t#4;+H1MYSsHZkMQ!;o9?@u2dAyQ)_K zvz3tWDn;)+z5o%i@vJGw9i{k{Cvbl!fzW9x-iJn)v^OOB_8t#vP2ipum!@IA0I2c; zK(6`e0KrV}@c^xE@%j(XeSp0uApxVP$ncDx&(f9f2ecWW0_x-*E|)wC$qAgF>Fvxx zhMbPKpL){>n!Uo9cXx>gYKjcM^D4lZep6hQQgqGU`>%>og zdPjFHD59iv#<6=#eFE1DKWJzl0o*qzgg0j%rWf^NO)XpCKO4wb{7tbUGsjm65SZgyc3V)FnSz~g~ zcQuH$iNX*qrglY8EzK;aRAS_Fc7taDALm4!YCfGho0GUaVK>t0ef)0FTi!q#u+^D= z$aS&=Sz|)1F%u4xtILgA6H6CPE1`8D0LNZTxoM`1FT4g>RgtH6xi*{@Bq>I}mb`YU z0mkGJUmjBCdmnQ5mig(#+bTkk+au z?3Y$wD=RTKDdxTTq4cLrqh-}d$Q24g$=F7s(Me5ZbP2Q|w?FxK&{`zN0KXk5FoYxz znhn?TbUOCk&JAkSP0-xxMux7siQATwD~SLUtANx7YZ5xyUK0oesXYhdF}JR`##Y51 zxTBbq>{#$(y&8}Lt%eT4hg-T$KV}oQsx7OgK_*pwdgqDhm?p|ZnxPUfCNB}>sCF}z z6Tq+|ol%O{1qBKMeL^^INJ9TQdQpk%t6+aN&vB|f1JxswH1}c+fG7wIz`$yu3zSOQ zq!hm#l8Sq;hEhMh3lKUg6r4_iRT)HTD93XY>TU93cPgXUwZ;7xfFMP*i)d<8Ms8n*%c>73%)Gh#;MCV(SV^aNN`F*}w^Iawz_K9WW^INETqWM^ldi#Mk$09Ut(O$|ZlmhHNzFx{|d zHrf6&|H<9OqWV}J25>WA#g4TeuiC$GH|BWWVEmnYTRPSwx3y!ms{CXe(ye~Eay#;`~5JyW7Kh|bTfXK~r z$rzT5VJR{!Il@w!kbm93lp>ch!cx7o+=_*>2#rv|%#gQv#`CVDhdw2&E}cD$J< z1W26FEKUIaIl{uh8>h(IPk#K?RepaAYXP_6U)V64y7wcof}L`ZGT^v>MuX3{f}iDq zD?9|x_F@JR!WUj@9U);SYoQ@XUp z4=Lo5SB1yM#)4=MHj=V^=^}GtE60Za`*(}~ks{|!Oh_r>&T$e(iP_l7Si!_}d9$wa zXS8L0Y-tq|`QB%qWNmAkL7N$L%U1ggTKwCJl}(X4>GkwsS!Pv8Tf$|+;zw)$@7zJE Z{uFk9Z^CkodJqs`-_K5a@_#}T{{uy?IH~{u literal 0 HcmV?d00001 diff --git a/file/welcome.png b/file/welcome.png new file mode 100644 index 0000000000000000000000000000000000000000..f73a6e6911b31c3f91bec224707f84d6e61f2c12 GIT binary patch literal 9888 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 literal 0 HcmV?d00001 diff --git a/readme.md b/readme.md index e69de29..6593273 100644 --- a/readme.md +++ b/readme.md @@ -0,0 +1,70 @@ +# 실시간 채팅 웹 사이트 + +Version1 : From 23-09-13 To 23-09-20 + +## 1. 기획 의도 + +- STOMP 프로토콜을 이용한 실시간 채팅 서비스 기능을 개발하여 STOMP 기술을 익힌다. +- 채팅 기능 구현을 통해서 동기화를 고려한 개발을 진행해본다. +- 메시지 브로커 (RabbitMQ, Kafka) 서비스를 이용해서 아키텍처를 구성한다. + +## 2. 개발 기능 + +- 회원 기능 + - 간단 회원가입 + - 로그인 + - 친구 추가 + - 친구 삭제 + - 친구 목록 조회 +- 채팅 기능 + - 채팅 입력 + - 채팅 삭제 + - 채팅 목록 조회 +- 채팅방 기능 + - 채팅방 생성 + - 채팅방 조회 + - 채팅방 나가기 + - 친구 초대하기 + - 채팅방 배경색 설정 + - 채팅 안 읽음 확인 + +## 3. 아키텍처 + +### version 1 + +browser → server → db + +### version 2 + +browser → messageQueue → server → db + +## 4. ERD 설계 +![erd image](/file/erd.png) +[erd cloud link](https://www.erdcloud.com/d/44AHnBQQtTh4HtfwD) + +## 5. 사용 기술 + +version 1 + +- `spring boot` `spring websocket` `STOMP` `JPA` + +version 2 + +- `rabbitMQ` + + +## 6. 개발 UI + +| 이름 | 상세 | 화면 | +|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------| +| 로그인 페이지 | 닉네임과 비밀번호를 통한 로그인 페이지로서 모든 입력 값을 입력해야 로그인 버튼이 동작한다. | ![로그인](/file/login.png) | +| 회원가입 페이지 | 닉네임과 비밀번호를 통한 회원가입 페이지로서 모든 입력 값을 입력해야 회원가입 버튼이 동작한다. | ![회원가입](/file/welcome.png) | +| 친구추가 페이지 | 친구의 닉네임으로 친구를 추가할 수 있다. | ![친구추가](/file/addFriend.png) | +| 서비스 간단 설명 페이지 | 1. 채팅방 만들기 : `채팅방 만들기` 버튼을 클릭하면 채팅방 추가를 위한 인터페이스 제공한다. 말풍선 아이콘을 클릭하면 채팅방을 생성한다.

    2. 채팅방 변경 : 채팅방 목록에서 채팅방을 클릭하면 해당 채팅방으로 이동한다.

    3. 채팅 삭제 : 스스로 생성한 하얀색 채팅은 클릭시 삭제된다.

    4. 친구 초대: 친구의 닉네임 입력후 초대 아이콘을 클릭하면 친구 해당 채팅방에 초대한다. | ![간단설명](/file/chat_info.png) | + + + + + + + diff --git a/src/main/java/com/websocket/demo/service/UserService.java b/src/main/java/com/websocket/demo/service/UserService.java index 2346d1c..7f2c827 100644 --- a/src/main/java/com/websocket/demo/service/UserService.java +++ b/src/main/java/com/websocket/demo/service/UserService.java @@ -10,7 +10,6 @@ import com.websocket.demo.response.FriendInfo; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import java.util.List; diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index e15cb38..7b01cd4 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -62,6 +62,7 @@ + @@ -233,11 +237,10 @@

    친구

  • + class="flex dark:hover:bg-gray-700 hover:bg-gray-100 rounded-lg">