From 18f8c55dff128c822568eee5a3a23f49556b885e Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 17:05:12 +0900 Subject: [PATCH 01/21] fix: Fix error about get all rooms --- .../persistence/ProjectPersistenceAdapter.java | 4 ++-- .../application/service/ProjectService.java | 3 ++- .../java/com/syncd/domain/project/Project.java | 16 +++++++++------- .../com/syncd/domain/project/UserInProject.java | 9 +++++++-- .../ProjectPersistenceAdapterTest.java | 3 ++- .../persistence/project/ReadProjectPortTest.java | 3 ++- .../project/WriteProjectPortTest.java | 3 ++- src/test/java/domain/project/ProjectTest.java | 3 ++- 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java index 7a44c8f..cbe9a0d 100644 --- a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java +++ b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java @@ -23,10 +23,11 @@ public class ProjectPersistenceAdapter implements WriteProjectPort, ReadProjectP @Override public List findAllProjectByUserId(String userId){ List projectEntityList = projectDao.findByUsersUserId(userId); + System.out.println(projectEntityList); List projects = projectEntityList.stream() .map(ProjectMapper.INSTANCE::fromProjectEntity) .collect(Collectors.toList()); - + System.out.println(projects); return projects; } @@ -36,7 +37,6 @@ public Project findProjectByProjectId(String projectId){ .map(ProjectMapper.INSTANCE::fromProjectEntity) .orElseThrow(() -> new ProjectNotFoundException("No project found with ID: " + projectId)); } - @Override public String CreateProject(Project project) { if (project.getId() != null && projectDao.existsById(project.getId())) { diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index bb2ef13..16ff166 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -38,7 +38,8 @@ public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserId public CreateProjectResponseDto createProject(String hostId,String hostName, String projectName, String description, String img, List userEmails){ List users = readUserPort.usersFromEmails(userEmails); sendMailPort.sendIviteMailBatch(hostName,projectName,users); - Project project = new Project(projectName,description,img,hostId, users); + Project project = new Project(); + project=project.createProjectDomain(projectName,description,img,hostId, users); return new CreateProjectResponseDto(writeProjectPort.CreateProject(project)); } diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index 27069a0..e85ade6 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -63,13 +63,15 @@ public void syncProject(int progress){ // this.progress = 0; // this.lastModifiedDate = LocalDateTime.now().toString(); // } - public Project(String projectName, String description, String img, String hostId, List users){ - this.img = img; - this.users = userInProjectsFromUsers(hostId,users); - this.name = projectName; - this.description = description; - this.progress = 0; - this.lastModifiedDate = LocalDateTime.now().toString(); + public Project createProjectDomain(String projectName, String description, String img, String hostId, List users){ + Project project = new Project(); + project.setImg(img); + project.setUsers( userInProjectsFromUsers(hostId,users)); + project.setName(projectName); + project.setDescription(description); + project.setProgress(0); + project.setLastModifiedDate(LocalDateTime.now().toString()); + return project; } private List userInProjectsFromUsers(String hostId, List members){ if (members == null) { diff --git a/src/main/java/com/syncd/domain/project/UserInProject.java b/src/main/java/com/syncd/domain/project/UserInProject.java index bd6df11..5205222 100644 --- a/src/main/java/com/syncd/domain/project/UserInProject.java +++ b/src/main/java/com/syncd/domain/project/UserInProject.java @@ -10,8 +10,13 @@ @Data public class UserInProject { - private final String userId; - private final Role role; + private String userId; + private Role role; + + public UserInProject(String userId, Role role){ + this.userId=userId; + this.role = role; + } public static List userInProjectsFromUsers(String hostId, List members){ return Stream.concat( diff --git a/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java b/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java index 99ed5f1..43bd365 100644 --- a/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java +++ b/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java @@ -39,7 +39,8 @@ void setup() { projectEntity.setId("1"); String hostId = "hostId"; List userList = new ArrayList<>(); - project = new Project("Project Name", "Description", "img", hostId, userList); + project = new Project(); + project = project.createProjectDomain("Project Name", "Description", "img", hostId, userList); project.setId("1"); } diff --git a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java index 0c4d54d..cabff42 100644 --- a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java +++ b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java @@ -22,7 +22,8 @@ public class ReadProjectPortTest { void setUp(){ String hostId = "hostUserId"; List emptyUserList = new ArrayList<>(); - project = new Project("Project Name", "Description", "img", hostId, emptyUserList); + project = new Project(); + project = project.createProjectDomain("Project Name", "Description", "img", hostId, emptyUserList); project.setId("1"); project.setLastModifiedDate(LocalDateTime.now().toString()); project.setProgress(0); diff --git a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java b/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java index 1104693..37854d7 100644 --- a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java +++ b/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java @@ -19,7 +19,8 @@ public class WriteProjectPortTest { void setUp(){ String hostId = "hostUserId"; List userList = new ArrayList<>(); - project = new Project("Project Name", "Description", "img", hostId, userList); + project = new Project(); + project = project.createProjectDomain("Project Name", "Description", "img", hostId, userList); project.setId("1"); } @Test diff --git a/src/test/java/domain/project/ProjectTest.java b/src/test/java/domain/project/ProjectTest.java index 273b504..d412717 100644 --- a/src/test/java/domain/project/ProjectTest.java +++ b/src/test/java/domain/project/ProjectTest.java @@ -45,7 +45,8 @@ void setup() { userList.add(user1); userList.add(user2); - project = new Project("Project Name", "syncd", "img", hostId, userList); + project = new Project(); + project = project.createProjectDomain("Project Name", "syncd", "img", hostId, userList); project.setId("1"); } From 916f3b8560f1545a292ee9e14dc2d229e6b3f4e0 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 17:07:39 +0900 Subject: [PATCH 02/21] fix: Fix error about get all rooms --- .../adapter/out/persistence/ProjectPersistenceAdapter.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java index cbe9a0d..f529fb2 100644 --- a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java +++ b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java @@ -23,11 +23,9 @@ public class ProjectPersistenceAdapter implements WriteProjectPort, ReadProjectP @Override public List findAllProjectByUserId(String userId){ List projectEntityList = projectDao.findByUsersUserId(userId); - System.out.println(projectEntityList); List projects = projectEntityList.stream() .map(ProjectMapper.INSTANCE::fromProjectEntity) .collect(Collectors.toList()); - System.out.println(projects); return projects; } From ab9e119e189648758bc239e02be1823572895c62 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 21:14:54 +0900 Subject: [PATCH 03/21] feat: Make userstory logic with openai --- build.gradle | 4 + .../adapter/in/web/ProjectController.java | 12 + .../adapter/out/openai/ChatGPTAdapter.java | 222 ++++++++++++++++++ .../adapter/out/openai/ChatGPTConfig.java | 25 ++ .../repository/project/ProjectEntity.java | 1 + .../port/in/MakeUserstoryUsecase.java | 10 + .../port/out/openai/ChatGPTPort.java | 27 +++ .../application/service/ProjectService.java | 24 +- .../com/syncd/domain/project/Project.java | 5 + .../java/com/syncd/dto/ChatRequestDto.java | 40 ++++ .../syncd/dto/MakeUserStoryReauestDto.java | 12 + .../syncd/dto/MakeUserStoryResponseDto.java | 23 ++ src/main/java/com/syncd/dto/OpenAIToken.java | 11 + .../NotIncludeProjectException.java | 13 + .../exceptions/NotLeftChanceException.java | 13 + .../syncd/exceptions/enums/ExceptionType.java | 7 +- .../handler/UserExceptionHandler.java | 16 ++ 17 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java create mode 100644 src/main/java/com/syncd/adapter/out/openai/ChatGPTConfig.java create mode 100644 src/main/java/com/syncd/application/port/in/MakeUserstoryUsecase.java create mode 100644 src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java create mode 100644 src/main/java/com/syncd/dto/ChatRequestDto.java create mode 100644 src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java create mode 100644 src/main/java/com/syncd/dto/MakeUserStoryResponseDto.java create mode 100644 src/main/java/com/syncd/dto/OpenAIToken.java create mode 100644 src/main/java/com/syncd/exceptions/NotIncludeProjectException.java create mode 100644 src/main/java/com/syncd/exceptions/NotLeftChanceException.java diff --git a/build.gradle b/build.gradle index f1621d2..b8ee766 100644 --- a/build.gradle +++ b/build.gradle @@ -94,6 +94,10 @@ dependencies { implementation group: 'jakarta.mail', name: 'jakarta.mail-api', version: '2.1.2' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation("org.springframework.retry:spring-retry") + implementation 'org.aspectj:aspectjrt:1.9.7' + implementation 'org.aspectj:aspectjweaver:1.9.7' + } test { diff --git a/src/main/java/com/syncd/adapter/in/web/ProjectController.java b/src/main/java/com/syncd/adapter/in/web/ProjectController.java index 13aa9e3..d9813b6 100644 --- a/src/main/java/com/syncd/adapter/in/web/ProjectController.java +++ b/src/main/java/com/syncd/adapter/in/web/ProjectController.java @@ -8,9 +8,13 @@ import com.syncd.application.port.in.UpdateProjectUsecase.*; import com.syncd.application.port.in.DeleteProjectUsecase.*; import com.syncd.application.port.in.SyncProjectUsecase.*; +import com.syncd.dto.MakeUserStoryReauestDto; +import com.syncd.dto.MakeUserStoryResponseDto; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @@ -29,6 +33,7 @@ public class ProjectController { private final UpdateProjectUsecase updateProjectUsecase; private final SyncProjectUsecase syncProjectUsecase; + private final MakeUserstoryUsecase makeUserstoryUsecase; private final JwtTokenProvider jwtTokenProvider; @@ -69,4 +74,11 @@ public SyncProjectResponseDto syncProject(HttpServletRequest request, @Valid @Re return syncProjectUsecase.syncProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId(), requestDto.projectStage()); } + @PostMapping("/userstory") + public ResponseEntity makeUserStory(HttpServletRequest request, @RequestBody MakeUserStoryReauestDto makeUserStoryReauestDto) { + String token = jwtTokenProvider.resolveToken(request); + MakeUserStoryResponseDto result = makeUserstoryUsecase.makeUserstory(jwtTokenProvider.getUserIdFromToken(token), makeUserStoryReauestDto.getProjectId(), makeUserStoryReauestDto.getSenario()); + return new ResponseEntity<>(result, HttpStatus.OK); + } + } diff --git a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java new file mode 100644 index 0000000..e832ac7 --- /dev/null +++ b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java @@ -0,0 +1,222 @@ +package com.syncd.adapter.out.openai; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.syncd.application.port.out.openai.ChatGPTPort; +import com.syncd.dto.ChatRequestDto; +import com.syncd.dto.MakeUserStoryResponseDto; +import com.syncd.dto.OpenAIToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatGPTAdapter implements ChatGPTPort { + private final ChatGPTConfig chatGPTConfig; + private final RestTemplate restTemplate; + private final ObjectMapper om; + @Value("${spring.security.openai.model}") + private String model; + + @Value("${spring.security.openai.promptForEpic}") + private String promptForEpic; + + @Value("${spring.security.openai.promptForUserstory}") + private String promptForUserStory; + + @Override + public MakeUserStoryResponseDto makeUserstory(List scenario) { + OpenAIToken finalToken = new OpenAIToken(); + try { + MakeUserStoryResponseDto responseDto = promptUserStory(finalToken, om, scenario); + System.out.println(finalToken); + return responseDto; + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + @Retryable(value = { NullPointerException.class }, maxAttempts = 2, recover = "recoverPromptUserStory") + private MakeUserStoryResponseDto promptUserStory(OpenAIToken finalToken, ObjectMapper om, List scenario) throws JsonProcessingException,NullPointerException { + Logger logger = LoggerFactory.getLogger(this.getClass()); + String requestTextForEpic = createRequestForEpic(scenario); + Map Epic = prompt(finalToken, om, requestTextForEpic); + + String requestTextForUserstory = promptForUserStory + getMessage(Epic); + Map userStory = prompt(finalToken, om, requestTextForUserstory); + + String res = extractJson(getMessage(userStory)); + if (res == null) { + throw new NullPointerException("The response 'res' is null and cannot be processed."); + } + res = res.replace('\'', '\"'); + return om.readValue(res, MakeUserStoryResponseDto.class); + } + + + @Recover + public ResponseEntity recoverPromptUserStory(Exception e, OpenAIToken finalToken, ObjectMapper om, List scenario) { + return ResponseEntity.badRequest().body("{\"error\": \"부적절한 시나리오입니다. 시나리오를 확인해주세요.\"}"); + } + + @Override + public List> modelList() { + List> resultList = null; + + HttpHeaders headers = chatGPTConfig.httpHeaders(); + + ResponseEntity response = restTemplate + .exchange( + "https://api.openai.com/v1/models", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + ObjectMapper om = new ObjectMapper(); + Map data = parseJsonResponse(om, (String) response.getBody()); + + resultList = (List>) data.get("data"); + for (Map Object : resultList) { + log.debug("ID: " + Object.get("id")); + log.debug("Object: " + Object.get("Object")); + log.debug("Created: " + Object.get("created")); + log.debug("Owned By: " + Object.get("owned_by")); + } + return resultList; + } + + + @Override + public Map isValidModel(String modelName) { + Map result; + + HttpHeaders headers = chatGPTConfig.httpHeaders(); + + ResponseEntity response = restTemplate + .exchange( + "https://api.openai.com/v1/models/" + modelName, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + ObjectMapper om = new ObjectMapper(); + result = parseJsonResponse(om, (String) response.getBody()); + return result; + } + private static ChatRequestDto.MessageDto createMessageDto(String role, String content) { + return ChatRequestDto.MessageDto.builder() + .role(role) + .content(content) + .build(); + } + + private ChatRequestDto createAskChatRequestDto(String requestText) { + ChatRequestDto.MessageDto promptForEpic = createMessageDto("user", requestText); + + return ChatRequestDto.builder() + .model(model) + .messages(Collections.singletonList(promptForEpic)) + .build(); + } + + private String createRequestForEpic(List scenario){ + return promptForEpic+"'" + String.join("','", scenario)+ "'"; + } + + private void addToken(OpenAIToken finalToken, Map res) { + OpenAIToken tempToken = getToken(res); + finalToken.setInToken(finalToken.getInToken() + tempToken.getInToken()); + finalToken.setOutToken(finalToken.getOutToken() + tempToken.getOutToken()); + } + private Map prompt(OpenAIToken finalToken, ObjectMapper om, String req) throws JsonProcessingException { + ChatRequestDto chatRequestDtoForEpic = createAskChatRequestDto(req); + + String requestBody = om.writeValueAsString(chatRequestDtoForEpic); + + HttpHeaders headers = chatGPTConfig.httpHeaders(); + + HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange( + "https://api.openai.com/v1/chat/completions", + HttpMethod.POST, + requestEntity, + String.class); + + String responseBody = response.getBody(); + + Map res = parseJsonResponse(om, responseBody); + addToken(finalToken,res); + return res; + } + + private String getMessage( Map res){ + + List> choices = (List>) res.get("choices"); + + Map firstChoice = choices.get(0); + + Map message = (Map) firstChoice.get("message"); + + String content = (String) message.get("content"); + System.out.println(content); + return content; + } + + private OpenAIToken getToken(Map res){ + + Map usage = (Map) res.get("usage"); + + int inToken = (int) usage.get("prompt_tokens"); + int outToken = (int) usage.get("completion_tokens"); + + OpenAIToken token = new OpenAIToken(); + token.setInToken(inToken); + token.setOutToken(outToken); + return token; + } + private static String extractJson(String text) { + int startIndex = text.indexOf('{'); + int endIndex = text.lastIndexOf('}'); + + if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { + return text.substring(startIndex, endIndex + 1); + } + return null; + } + +// private String getResult( Map epic){ +// +// String content = getMessage(epic); +// +// int resultIndex = content.indexOf(resultPrefix); +// +// String result = resultIndex != -1 ? content.substring(resultIndex + resultPrefix.length()).trim() : "Result not found"; +// +// return result; +// } + + private static Map parseJsonResponse(ObjectMapper om,String jsonResponse) { + try { + return om.readValue(jsonResponse, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/syncd/adapter/out/openai/ChatGPTConfig.java b/src/main/java/com/syncd/adapter/out/openai/ChatGPTConfig.java new file mode 100644 index 0000000..e2ca912 --- /dev/null +++ b/src/main/java/com/syncd/adapter/out/openai/ChatGPTConfig.java @@ -0,0 +1,25 @@ +package com.syncd.adapter.out.openai; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + + +@Configuration +public class ChatGPTConfig { + + @Value("${spring.security.openai.secretKey}") + private String secretKey; + + @Bean + public HttpHeaders httpHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + secretKey); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } +} \ No newline at end of file diff --git a/src/main/java/com/syncd/adapter/out/persistence/repository/project/ProjectEntity.java b/src/main/java/com/syncd/adapter/out/persistence/repository/project/ProjectEntity.java index 1372b0e..a6d1aca 100644 --- a/src/main/java/com/syncd/adapter/out/persistence/repository/project/ProjectEntity.java +++ b/src/main/java/com/syncd/adapter/out/persistence/repository/project/ProjectEntity.java @@ -18,6 +18,7 @@ public class ProjectEntity { private List users; private int progress; private String lastModifiedDate; + private int leftChanceForUserstory; @Data public static class UserInProjectEntity { diff --git a/src/main/java/com/syncd/application/port/in/MakeUserstoryUsecase.java b/src/main/java/com/syncd/application/port/in/MakeUserstoryUsecase.java new file mode 100644 index 0000000..ca03b54 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/MakeUserstoryUsecase.java @@ -0,0 +1,10 @@ +package com.syncd.application.port.in; + +import com.syncd.dto.MakeUserStoryResponseDto; +import lombok.Data; + +import java.util.List; + +public interface MakeUserstoryUsecase { + MakeUserStoryResponseDto makeUserstory(String userId, String projectId, List scenarios); +} diff --git a/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java new file mode 100644 index 0000000..0b63509 --- /dev/null +++ b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java @@ -0,0 +1,27 @@ +package com.syncd.application.port.out.openai; + +import com.syncd.application.port.in.MakeUserstoryUsecase; +import com.syncd.dto.MakeUserStoryResponseDto; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * ChatGPT 서비스 인터페이스 + * + * @author : lee + * @fileName : ChatGPTService + * @since : 12/29/23 + */ + +@Service +public interface ChatGPTPort { + + List> modelList(); + + MakeUserStoryResponseDto makeUserstory(List senarios); + + Map isValidModel(String modelName); + +} \ No newline at end of file diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index 16ff166..d34ee72 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -3,19 +3,23 @@ import com.syncd.application.port.in.*; import com.syncd.application.port.out.gmail.SendMailPort; import com.syncd.application.port.out.liveblock.LiveblocksPort; +import com.syncd.application.port.out.openai.ChatGPTPort; import com.syncd.application.port.out.persistence.project.ReadProjectPort; import com.syncd.application.port.out.persistence.project.WriteProjectPort; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.domain.project.Project; import com.syncd.domain.project.UserInProject; import com.syncd.domain.user.User; +import com.syncd.dto.MakeUserStoryResponseDto; import com.syncd.dto.UserRoleDto; import com.syncd.enums.Role; +import com.syncd.exceptions.NotLeftChanceException; import com.syncd.exceptions.ProjectAlreadyExistsException; import com.syncd.exceptions.UserNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; @@ -27,12 +31,13 @@ @Service @Primary @RequiredArgsConstructor -public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserIdUsecase, GetRoomAuthTokenUsecase, UpdateProjectUsecase, WithdrawUserInProjectUsecase, InviteUserInProjectUsecase, DeleteProjectUsecase, SyncProjectUsecase { +public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserIdUsecase, GetRoomAuthTokenUsecase, UpdateProjectUsecase, WithdrawUserInProjectUsecase, InviteUserInProjectUsecase, DeleteProjectUsecase, SyncProjectUsecase, MakeUserstoryUsecase { private final ReadProjectPort readProjectPort; private final WriteProjectPort writeProjectPort; private final ReadUserPort readUserPort; private final LiveblocksPort liveblocksPort; private final SendMailPort sendMailPort; + private final ChatGPTPort chatGPTPort; @Override public CreateProjectResponseDto createProject(String hostId,String hostName, String projectName, String description, String img, List userEmails){ @@ -120,6 +125,23 @@ public SyncProjectResponseDto syncProject(String userId, String projectId, int p return new SyncProjectResponseDto(projectId); } + @Override + @Transactional + public MakeUserStoryResponseDto makeUserstory(String userId, String projectId, List senarios){ + Project project = readProjectPort.findProjectByProjectId(projectId); + if(project.getLeftChanceForUserstory() < 1){ + throw new NotLeftChanceException(projectId); + } + boolean containsUserIdA = project.getUsers().stream() + .anyMatch(user -> user.getUserId().equals(userId)); + + if(containsUserIdA){ + throw new NotLeftChanceException(projectId); + } + project.subLeftChanceForUserstory(); + writeProjectPort.UpdateProject(project); + return chatGPTPort.makeUserstory(senarios); + } // ====================================== // private methods (implements) diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index e85ade6..eeb35d6 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -20,6 +20,7 @@ public class Project { private List users; private int progress; private String lastModifiedDate; + private int leftChanceForUserstory; public void addUsers(List newUsers) { if (this.users == null) { @@ -55,6 +56,10 @@ public void syncProject(int progress){ this.lastModifiedDate = LocalDateTime.now().toString(); } + public void subLeftChanceForUserstory(){ + this.leftChanceForUserstory = this.leftChanceForUserstory-1; + } + // public Project(String projectName, String description, String img, List users){ // this.img = img; // this.users = users; diff --git a/src/main/java/com/syncd/dto/ChatRequestDto.java b/src/main/java/com/syncd/dto/ChatRequestDto.java new file mode 100644 index 0000000..3ba96b2 --- /dev/null +++ b/src/main/java/com/syncd/dto/ChatRequestDto.java @@ -0,0 +1,40 @@ +package com.syncd.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRequestDto { + + private String model; + + private List messages; + + @Builder + ChatRequestDto(String model, List messages) { + this.model = model; + this.messages = messages; + } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class MessageDto { + + private String role; + + private String content; + + @Builder + MessageDto(String role, String content) { + this.role = role; + this.content = content; + } + + } +} + + diff --git a/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java b/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java new file mode 100644 index 0000000..a2ab6d8 --- /dev/null +++ b/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java @@ -0,0 +1,12 @@ +package com.syncd.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class MakeUserStoryReauestDto { + private String projectId; + private List senario; + +} \ No newline at end of file diff --git a/src/main/java/com/syncd/dto/MakeUserStoryResponseDto.java b/src/main/java/com/syncd/dto/MakeUserStoryResponseDto.java new file mode 100644 index 0000000..8b68e92 --- /dev/null +++ b/src/main/java/com/syncd/dto/MakeUserStoryResponseDto.java @@ -0,0 +1,23 @@ +package com.syncd.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class MakeUserStoryResponseDto { + + private List epics; + + @Data + public static class EpicDto { + private String id; + private String name; + private List userStories; + } + @Data + public static class UserStoryDto { + private int id; + private String name; + } +} \ No newline at end of file diff --git a/src/main/java/com/syncd/dto/OpenAIToken.java b/src/main/java/com/syncd/dto/OpenAIToken.java new file mode 100644 index 0000000..11e6eb5 --- /dev/null +++ b/src/main/java/com/syncd/dto/OpenAIToken.java @@ -0,0 +1,11 @@ +package com.syncd.dto; + +import lombok.Data; + +@Data +public class OpenAIToken { + + private int inToken; + private int outToken; + +} \ No newline at end of file diff --git a/src/main/java/com/syncd/exceptions/NotIncludeProjectException.java b/src/main/java/com/syncd/exceptions/NotIncludeProjectException.java new file mode 100644 index 0000000..2133eca --- /dev/null +++ b/src/main/java/com/syncd/exceptions/NotIncludeProjectException.java @@ -0,0 +1,13 @@ +package com.syncd.exceptions; + +import com.syncd.exceptions.enums.ExceptionType; + +public class NotIncludeProjectException extends RuntimeException { + public NotIncludeProjectException() { + super(ExceptionType.NOT_LEFT_CHANCE.getMessage()); + } + + public NotIncludeProjectException(String projectId){ + super("Project ID " + projectId + " : " + ExceptionType.NOT_LEFT_CHANCE.getMessage()); + } +} diff --git a/src/main/java/com/syncd/exceptions/NotLeftChanceException.java b/src/main/java/com/syncd/exceptions/NotLeftChanceException.java new file mode 100644 index 0000000..a1b4dac --- /dev/null +++ b/src/main/java/com/syncd/exceptions/NotLeftChanceException.java @@ -0,0 +1,13 @@ +package com.syncd.exceptions; + +import com.syncd.exceptions.enums.ExceptionType; + +public class NotLeftChanceException extends RuntimeException { + public NotLeftChanceException() { + super(ExceptionType.NOT_INCLUDE_PROJECT.getMessage()); + } + + public NotLeftChanceException(String projectId){ + super("Project ID " + projectId + " : " + ExceptionType.NOT_INCLUDE_PROJECT.getMessage()); + } +} diff --git a/src/main/java/com/syncd/exceptions/enums/ExceptionType.java b/src/main/java/com/syncd/exceptions/enums/ExceptionType.java index 76e0a8d..0c9cb77 100644 --- a/src/main/java/com/syncd/exceptions/enums/ExceptionType.java +++ b/src/main/java/com/syncd/exceptions/enums/ExceptionType.java @@ -3,12 +3,17 @@ import org.springframework.http.HttpStatus; public enum ExceptionType { + REGISTER_ERROR(HttpStatus.BAD_REQUEST, "Registration error"), LOGIN_ERROR(HttpStatus.UNAUTHORIZED, "Login error"), PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "Project not found"), PROJECT_OPERATION_ERROR(HttpStatus.BAD_REQUEST, "Project operation error"), PROJECT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "Project already exists"), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"); + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"), + + NOT_LEFT_CHANCE(HttpStatus.NOT_FOUND, "Not Left Chance"), + NOT_INCLUDE_PROJECT(HttpStatus.NOT_FOUND, "Not include that project"); + private final HttpStatus status; private final String message; diff --git a/src/main/java/com/syncd/exceptions/handler/UserExceptionHandler.java b/src/main/java/com/syncd/exceptions/handler/UserExceptionHandler.java index fb84b03..90916c1 100644 --- a/src/main/java/com/syncd/exceptions/handler/UserExceptionHandler.java +++ b/src/main/java/com/syncd/exceptions/handler/UserExceptionHandler.java @@ -51,4 +51,20 @@ public ResponseEntity handleProjectAlreadyExistsException(ProjectAlready return ResponseEntity.status(ExceptionType.PROJECT_ALREADY_EXISTS.getStatus()) .body(ExceptionType.PROJECT_ALREADY_EXISTS.getMessage()); } + + @ExceptionHandler(NotIncludeProjectException.class) + public ResponseEntity handleProjectNotIncludeProjectException(NotIncludeProjectException ex) { + logger.error(ExceptionType.NOT_INCLUDE_PROJECT.getMessage(), ex); + Sentry.captureException(ex); + return ResponseEntity.status(ExceptionType.NOT_INCLUDE_PROJECT.getStatus()) + .body(ExceptionType.NOT_INCLUDE_PROJECT.getMessage()); + } + + @ExceptionHandler(NotLeftChanceException.class) + public ResponseEntity handleProjectNotLeftChanceException(NotLeftChanceException ex) { + logger.error(ExceptionType.NOT_LEFT_CHANCE.getMessage(), ex); + Sentry.captureException(ex); + return ResponseEntity.status(ExceptionType.NOT_LEFT_CHANCE.getStatus()) + .body(ExceptionType.NOT_LEFT_CHANCE.getMessage()); + } } From daf05010f777d476fdd297593eda11fcb5b17e7d Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 21:17:27 +0900 Subject: [PATCH 04/21] fire: Fix out port --- .../adapter/out/openai/ChatGPTAdapter.java | 51 ------------------- .../port/out/openai/ChatGPTPort.java | 2 - 2 files changed, 53 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java index e832ac7..14fd084 100644 --- a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java +++ b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java @@ -76,49 +76,8 @@ public ResponseEntity recoverPromptUserStory(Exception e, OpenAIToken finalTo return ResponseEntity.badRequest().body("{\"error\": \"부적절한 시나리오입니다. 시나리오를 확인해주세요.\"}"); } - @Override - public List> modelList() { - List> resultList = null; - - HttpHeaders headers = chatGPTConfig.httpHeaders(); - - ResponseEntity response = restTemplate - .exchange( - "https://api.openai.com/v1/models", - HttpMethod.GET, - new HttpEntity<>(headers), - String.class); - - ObjectMapper om = new ObjectMapper(); - Map data = parseJsonResponse(om, (String) response.getBody()); - - resultList = (List>) data.get("data"); - for (Map Object : resultList) { - log.debug("ID: " + Object.get("id")); - log.debug("Object: " + Object.get("Object")); - log.debug("Created: " + Object.get("created")); - log.debug("Owned By: " + Object.get("owned_by")); - } - return resultList; - } - @Override - public Map isValidModel(String modelName) { - Map result; - - HttpHeaders headers = chatGPTConfig.httpHeaders(); - - ResponseEntity response = restTemplate - .exchange( - "https://api.openai.com/v1/models/" + modelName, - HttpMethod.GET, - new HttpEntity<>(headers), - String.class); - ObjectMapper om = new ObjectMapper(); - result = parseJsonResponse(om, (String) response.getBody()); - return result; - } private static ChatRequestDto.MessageDto createMessageDto(String role, String content) { return ChatRequestDto.MessageDto.builder() .role(role) @@ -200,16 +159,6 @@ private static String extractJson(String text) { return null; } -// private String getResult( Map epic){ -// -// String content = getMessage(epic); -// -// int resultIndex = content.indexOf(resultPrefix); -// -// String result = resultIndex != -1 ? content.substring(resultIndex + resultPrefix.length()).trim() : "Result not found"; -// -// return result; -// } private static Map parseJsonResponse(ObjectMapper om,String jsonResponse) { try { diff --git a/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java index 0b63509..2edb285 100644 --- a/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java +++ b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java @@ -18,10 +18,8 @@ @Service public interface ChatGPTPort { - List> modelList(); MakeUserStoryResponseDto makeUserstory(List senarios); - Map isValidModel(String modelName); } \ No newline at end of file From c9643f765534c57af6ceb3dff56f722d3a29c352 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 21:18:57 +0900 Subject: [PATCH 05/21] fix: Add environment for openai --- src/main/resources/application.yml.sample | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application.yml.sample b/src/main/resources/application.yml.sample index 52ed0fd..937bdf2 100644 --- a/src/main/resources/application.yml.sample +++ b/src/main/resources/application.yml.sample @@ -13,6 +13,11 @@ spring: database: ${SPRING_DATA_MONGODB_DATABASE} uri: ${SPRING_DATA_MONGODB_URI} security: + openai: + secretKey: ${OPENAI_SECRET_KEY} + model: ${OPENAI_MODEL} + promptForEpic: ${OPENAI_PROMPT_FOR_EPIC} + promptForUserstory: ${OPENAI_PROMPT_FOR_USERSTORY} auth: liveBlockSecretKey: ${SPRING_SECURITY_AUTH_LIVEBLOCKSECRETKEY} oauth2: From 1e03d7c177754826ec3e4fba3e91e5a16952a938 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 13 May 2024 21:28:06 +0900 Subject: [PATCH 06/21] fix: Add check left chance --- .../java/com/syncd/adapter/in/web/ProjectController.java | 2 +- .../java/com/syncd/application/service/ProjectService.java | 7 +++++-- src/main/java/com/syncd/domain/project/Project.java | 1 + src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/syncd/adapter/in/web/ProjectController.java b/src/main/java/com/syncd/adapter/in/web/ProjectController.java index d9813b6..649cfa4 100644 --- a/src/main/java/com/syncd/adapter/in/web/ProjectController.java +++ b/src/main/java/com/syncd/adapter/in/web/ProjectController.java @@ -77,7 +77,7 @@ public SyncProjectResponseDto syncProject(HttpServletRequest request, @Valid @Re @PostMapping("/userstory") public ResponseEntity makeUserStory(HttpServletRequest request, @RequestBody MakeUserStoryReauestDto makeUserStoryReauestDto) { String token = jwtTokenProvider.resolveToken(request); - MakeUserStoryResponseDto result = makeUserstoryUsecase.makeUserstory(jwtTokenProvider.getUserIdFromToken(token), makeUserStoryReauestDto.getProjectId(), makeUserStoryReauestDto.getSenario()); + MakeUserStoryResponseDto result = makeUserstoryUsecase.makeUserstory(jwtTokenProvider.getUserIdFromToken(token), makeUserStoryReauestDto.getProjectId(), makeUserStoryReauestDto.getScenario()); return new ResponseEntity<>(result, HttpStatus.OK); } diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index d34ee72..4cc00f7 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -13,6 +13,7 @@ import com.syncd.dto.MakeUserStoryResponseDto; import com.syncd.dto.UserRoleDto; import com.syncd.enums.Role; +import com.syncd.exceptions.NotIncludeProjectException; import com.syncd.exceptions.NotLeftChanceException; import com.syncd.exceptions.ProjectAlreadyExistsException; import com.syncd.exceptions.UserNotFoundException; @@ -132,14 +133,16 @@ public MakeUserStoryResponseDto makeUserstory(String userId, String projectId, L if(project.getLeftChanceForUserstory() < 1){ throw new NotLeftChanceException(projectId); } + System.out.println(userId); boolean containsUserIdA = project.getUsers().stream() .anyMatch(user -> user.getUserId().equals(userId)); - if(containsUserIdA){ - throw new NotLeftChanceException(projectId); + if(!containsUserIdA){ + throw new NotIncludeProjectException(projectId); } project.subLeftChanceForUserstory(); writeProjectPort.UpdateProject(project); + System.out.println(senarios); return chatGPTPort.makeUserstory(senarios); } diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index eeb35d6..b6a6c2a 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -76,6 +76,7 @@ public Project createProjectDomain(String projectName, String description, Strin project.setDescription(description); project.setProgress(0); project.setLastModifiedDate(LocalDateTime.now().toString()); + project.setLeftChanceForUserstory(3); return project; } private List userInProjectsFromUsers(String hostId, List members){ diff --git a/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java b/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java index a2ab6d8..f9c3d84 100644 --- a/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java +++ b/src/main/java/com/syncd/dto/MakeUserStoryReauestDto.java @@ -7,6 +7,6 @@ @Data public class MakeUserStoryReauestDto { private String projectId; - private List senario; + private List scenario; } \ No newline at end of file From dace2efdb634d52d36d1f76876f1d155c1bbaa89 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Tue, 14 May 2024 00:25:12 +0900 Subject: [PATCH 07/21] fix: Fix error --- .../java/com/syncd/adapter/out/openai/ChatGPTAdapter.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java index 14fd084..452dc08 100644 --- a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java +++ b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java @@ -59,7 +59,7 @@ private MakeUserStoryResponseDto promptUserStory(OpenAIToken finalToken, ObjectM String requestTextForEpic = createRequestForEpic(scenario); Map Epic = prompt(finalToken, om, requestTextForEpic); - String requestTextForUserstory = promptForUserStory + getMessage(Epic); + String requestTextForUserstory = promptForUserStory.replace("{epics}", getMessage(Epic)); Map userStory = prompt(finalToken, om, requestTextForUserstory); String res = extractJson(getMessage(userStory)); @@ -95,7 +95,9 @@ private ChatRequestDto createAskChatRequestDto(String requestText) { } private String createRequestForEpic(List scenario){ - return promptForEpic+"'" + String.join("','", scenario)+ "'"; + System.out.println(scenario); + String replacedString = promptForEpic.replace("{scenario}", "'" + String.join("','", scenario)+ "'"); + return replacedString; } private void addToken(OpenAIToken finalToken, Map res) { From 6fa6a71c623f293f5f73584c314796e16b32170a Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Tue, 14 May 2024 00:56:11 +0900 Subject: [PATCH 08/21] fix: Fix error --- .../adapter/out/openai/ChatGPTAdapter.java | 42 ++++++++++++------- .../java/com/syncd/dto/ChatRequestDto.java | 9 +++- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java index 452dc08..6d285c6 100644 --- a/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java +++ b/src/main/java/com/syncd/adapter/out/openai/ChatGPTAdapter.java @@ -57,11 +57,13 @@ public MakeUserStoryResponseDto makeUserstory(List scenario) { private MakeUserStoryResponseDto promptUserStory(OpenAIToken finalToken, ObjectMapper om, List scenario) throws JsonProcessingException,NullPointerException { Logger logger = LoggerFactory.getLogger(this.getClass()); String requestTextForEpic = createRequestForEpic(scenario); + System.out.println(requestTextForEpic); Map Epic = prompt(finalToken, om, requestTextForEpic); - - String requestTextForUserstory = promptForUserStory.replace("{epics}", getMessage(Epic)); + System.out.println(getMessage(Epic)); + String requestTextForUserstory = promptForUserStory.replace("{epics}", "\""+getMessage(Epic)+"\""); + System.out.println(requestTextForUserstory); Map userStory = prompt(finalToken, om, requestTextForUserstory); - + System.out.println(getMessage(userStory)); String res = extractJson(getMessage(userStory)); if (res == null) { throw new NullPointerException("The response 'res' is null and cannot be processed."); @@ -76,8 +78,6 @@ public ResponseEntity recoverPromptUserStory(Exception e, OpenAIToken finalTo return ResponseEntity.badRequest().body("{\"error\": \"부적절한 시나리오입니다. 시나리오를 확인해주세요.\"}"); } - - private static ChatRequestDto.MessageDto createMessageDto(String role, String content) { return ChatRequestDto.MessageDto.builder() .role(role) @@ -90,13 +90,14 @@ private ChatRequestDto createAskChatRequestDto(String requestText) { return ChatRequestDto.builder() .model(model) + .temperature(0.5) + .top_p(1) .messages(Collections.singletonList(promptForEpic)) .build(); } private String createRequestForEpic(List scenario){ - System.out.println(scenario); - String replacedString = promptForEpic.replace("{scenario}", "'" + String.join("','", scenario)+ "'"); + String replacedString = promptForEpic.replace("{scenario}", "\"'" + String.join("','", scenario)+ "'\""); return replacedString; } @@ -135,7 +136,6 @@ private String getMessage( Map res){ Map message = (Map) firstChoice.get("message"); String content = (String) message.get("content"); - System.out.println(content); return content; } @@ -152,16 +152,28 @@ private OpenAIToken getToken(Map res){ return token; } private static String extractJson(String text) { - int startIndex = text.indexOf('{'); - int endIndex = text.lastIndexOf('}'); - - if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { - return text.substring(startIndex, endIndex + 1); - } - return null; +// int startIndex = text.indexOf('{'); +// int endIndex = text.lastIndexOf('}'); +// +// if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) { +// return text.substring(startIndex, endIndex + 1); +// } +// return null; + return text; } +// private String getResult( Map epic){ +// +// String content = getMessage(epic); +// +// int resultIndex = content.indexOf(resultPrefix); +// +// String result = resultIndex != -1 ? content.substring(resultIndex + resultPrefix.length()).trim() : "Result not found"; +// +// return result; +// } + private static Map parseJsonResponse(ObjectMapper om,String jsonResponse) { try { return om.readValue(jsonResponse, new TypeReference<>() {}); diff --git a/src/main/java/com/syncd/dto/ChatRequestDto.java b/src/main/java/com/syncd/dto/ChatRequestDto.java index 3ba96b2..f823ebf 100644 --- a/src/main/java/com/syncd/dto/ChatRequestDto.java +++ b/src/main/java/com/syncd/dto/ChatRequestDto.java @@ -13,12 +13,19 @@ public class ChatRequestDto { private String model; + + private double temperature; + + private double top_p; + private List messages; @Builder - ChatRequestDto(String model, List messages) { + ChatRequestDto(String model, List messages, double temperature, double top_p) { this.model = model; this.messages = messages; + this.temperature = temperature; + this.top_p = top_p; } @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) From fbebcfa42f46e4294cbacc3cf1507b146c2e329b Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Tue, 14 May 2024 23:05:15 +0900 Subject: [PATCH 09/21] feat: Make color --- .../out/liveblock/LiveblockApiAdapter.java | 46 +++++++++++++++++-- .../application/service/ProjectService.java | 5 +- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java b/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java index 47f9cb9..30ecd65 100644 --- a/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java +++ b/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Value; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.function.Function; import java.util.stream.Collectors; @@ -34,7 +35,6 @@ public LiveblocksTokenDto GetRoomAuthToken(String userId, String name,String img String jsonBody = createJsonBody(userId,name,img,projectIds); HttpEntity request = new HttpEntity<>(jsonBody, headers); -// System.out.print(request); return restTemplate.postForObject(url, request, LiveblocksTokenDto.class); } @@ -51,18 +51,18 @@ private String createJsonBody(String userId,String name, String img, List Date: Tue, 14 May 2024 23:47:19 +0900 Subject: [PATCH 10/21] feat: Make login redirect uri --- .../syncd/adapter/in/web/LoginController.java | 11 +++++++++-- .../syncd/adapter/out/gmail/GmailAdapter.java | 16 +++++++--------- .../application/port/out/gmail/SendMailPort.java | 4 ++-- .../application/service/ProjectService.java | 12 +++++------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/syncd/adapter/in/web/LoginController.java b/src/main/java/com/syncd/adapter/in/web/LoginController.java index 3d3f5e4..3108598 100644 --- a/src/main/java/com/syncd/adapter/in/web/LoginController.java +++ b/src/main/java/com/syncd/adapter/in/web/LoginController.java @@ -4,6 +4,7 @@ import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.service.LoginService; import com.syncd.dto.TokenDto; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -18,11 +19,17 @@ public class LoginController { private final GoogleOAuth2Properties googleOAuth2Properties; @GetMapping("/login/google") - public RedirectView redirectToGoogleOAuth() { + public RedirectView redirectToGoogleOAuth(HttpServletRequest request) { String redirectUrl = googleOAuth2Properties.getRedirectUri(); + String targetUrl = request.getHeader("Referer")+"login/oauth2/code/google"; + if (targetUrl == null || targetUrl.isBlank()) { + // 기본 URL 설정 + targetUrl = redirectUrl; + } + System.out.println(targetUrl); String url = "https://accounts.google.com/o/oauth2/auth" + "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + - "&redirect_uri=" + redirectUrl + + "&redirect_uri=" + targetUrl + "&response_type=code" + "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; return new RedirectView(url); diff --git a/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java b/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java index e9878b3..cf1f079 100644 --- a/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java +++ b/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java @@ -19,8 +19,8 @@ public class GmailAdapter implements SendMailPort { private static int number; // 랜덤 인증 코드 @Override @Async - public String sendInviteMail(String email, String hostName, String userName, String projectName) { - MimeMessage message = createMail(email, hostName, userName, projectName); + public String sendInviteMail(String email, String hostName, String userName, String projectName, String projectId) { + MimeMessage message = createMail(email, hostName, userName, projectName,projectId); // 실제 메일 전송 javaMailSender.send(message); @@ -28,16 +28,16 @@ public String sendInviteMail(String email, String hostName, String userName, Str } @Override - public String sendIviteMailBatch(String hostName, String projectName, List users) { + public String sendIviteMailBatch(String hostName, String projectName, List users,String projectId) { users.forEach(user -> { - MimeMessage message = createMail(user.getEmail(), hostName, user.getName(), projectName); + MimeMessage message = createMail(user.getEmail(), hostName, user.getName(), projectName,projectId); javaMailSender.send(message); }); return projectName; } // 메일 양식 작성 - public MimeMessage createMail(String email, String hostName, String userName, String projectName){ + public MimeMessage createMail(String email, String hostName, String userName, String projectName, String projectId){ MimeMessage message = javaMailSender.createMimeMessage(); try { @@ -76,7 +76,7 @@ public MimeMessage createMail(String email, String hostName, String userName, St " display: inline-block;\n" + " padding: 10px 20px;\n" + " background-color: #007bff;\n" + - " color: #fff;\n" + + " color: #000;\n" + " text-decoration: none;\n" + " border-radius: 5px;\n" + " }\n" + @@ -86,9 +86,7 @@ public MimeMessage createMail(String email, String hostName, String userName, St "
\n" + "

싱크대 프로젝트에 초대받았습니다!

\n" + "

"+hostName+"님이 "+userName+"님을 "+projectName+" 프로젝트에 초대되었습니다. 아래 버튼을 클릭하시면 초대에 응하실 수 있습니다

\n" + - " 초대 수락\n" + - "

위의 버튼이 작동하지 않는 경우 브라우저에 다음 링크를 복사하여 붙여넣을 수도 있습니다:

\n" + - "

https://syncd.i-dear.org

\n" + + " 초대 수락\n" + "
\n" + "\n" + "\n"; diff --git a/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java b/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java index 5923a2d..6caa26d 100644 --- a/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java +++ b/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java @@ -7,7 +7,7 @@ public interface SendMailPort { @Async - String sendInviteMail(String email,String hostName, String userName, String projectName); + String sendInviteMail(String email,String hostName, String userName, String projectName, String ProjectId); - String sendIviteMailBatch(String hostName, String projectName, List users); + String sendIviteMailBatch(String hostName, String projectName, List users, String ProjectId); } diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index 7c62181..c0ff94d 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -47,7 +47,6 @@ public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserId @Override public CreateProjectResponseDto createProject(String hostId, String hostName, String projectName, String description, MultipartFile img, List userEmails){ List users = readUserPort.usersFromEmails(userEmails); - sendMailPort.sendIviteMailBatch(hostName, projectName, users); String imgURL = ""; if (img != null && !img.isEmpty()) { @@ -56,7 +55,8 @@ public CreateProjectResponseDto createProject(String hostId, String hostName, St } Project project = new Project(); - project = project.createProjectDomain(projectName, description, imgURL, hostId, users); + project = project.createProjectDomain(projectName, description, imgURL, hostId, null); + sendMailPort.sendIviteMailBatch(hostName, projectName, users,project.getId()); return new CreateProjectResponseDto(writeProjectPort.CreateProject(project)); } @@ -125,12 +125,10 @@ public InviteUserInProjectResponseDto inviteUserInProject(String userId, String User host = readUserPort.findByUserId(userId); List users = userEmails.stream() - .map(email -> createUserInProjectWithRoleMember(email, host.getName(), project.getName())) + .map(email -> createUserInProjectWithRoleMember(email, host.getName(), project.getName(), projectId)) .collect(Collectors.toList()); - project.addUsers(users); - writeProjectPort.UpdateProject(project); return new InviteUserInProjectResponseDto(projectId); } @@ -194,10 +192,10 @@ private void checkHost(Project project, String userId){ throw new CustomException(ErrorInfo.PROJECT_ALREADY_EXISTS, "project id" + project.getId()); } } - private UserInProject createUserInProjectWithRoleMember(String userEmail, String hostName, String projectName) { + private UserInProject createUserInProjectWithRoleMember(String userEmail, String hostName, String projectName,String projectId) { // 여기에 사용자 생성 및 역할 부여 로직 추가 User user = readUserPort.findByEmail(userEmail); - sendMailPort.sendInviteMail(userEmail, hostName, user.getName(), projectName); + sendMailPort.sendInviteMail(userEmail, hostName, user.getName(), projectName,projectId); return new UserInProject(user.getId(), Role.MEMBER); } From a99e9a0c220965c30418cce14fa6c1c4de747e47 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Wed, 15 May 2024 00:20:09 +0900 Subject: [PATCH 11/21] fix: Fix error --- src/main/java/com/syncd/adapter/in/web/LoginController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/syncd/adapter/in/web/LoginController.java b/src/main/java/com/syncd/adapter/in/web/LoginController.java index 3108598..957f0fe 100644 --- a/src/main/java/com/syncd/adapter/in/web/LoginController.java +++ b/src/main/java/com/syncd/adapter/in/web/LoginController.java @@ -21,15 +21,18 @@ public class LoginController { @GetMapping("/login/google") public RedirectView redirectToGoogleOAuth(HttpServletRequest request) { String redirectUrl = googleOAuth2Properties.getRedirectUri(); +// System.out.println(redirectUrl); String targetUrl = request.getHeader("Referer")+"login/oauth2/code/google"; + System.out.println(request.toString()); if (targetUrl == null || targetUrl.isBlank()) { // 기본 URL 설정 targetUrl = redirectUrl; } System.out.println(targetUrl); + String url = "https://accounts.google.com/o/oauth2/auth" + "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + - "&redirect_uri=" + targetUrl + + "&redirect_uri=" + redirectUrl + "&response_type=code" + "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; return new RedirectView(url); From ef80758f956386b6c964de83225c547e142320a2 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Thu, 16 May 2024 18:49:19 +0900 Subject: [PATCH 12/21] refactor: Seperate login port --- .../adapter/in/oauth/JwtTokenProvider.java | 83 ----------- .../com/syncd/adapter/in/oauth/JwtUtils.java | 55 -------- .../syncd/adapter/in/web/AuthController.java | 5 +- .../syncd/adapter/in/web/LoginController.java | 3 +- .../adapter/in/web/ProjectController.java | 39 +++--- .../syncd/adapter/in/web/RoomController.java | 16 +-- .../syncd/adapter/in/web/UserController.java | 8 +- .../com/syncd/adapter/in/web/WebConfig.java | 1 - .../adapter/in/web/WebSecurityConfig.java | 22 +-- .../port/in/GenerateTokenUsecase.java | 7 + .../port/in/GetUserIdFromTokenUsecase.java | 7 + .../port/in/GetUsernameFromTokenUsecase.java | 7 + .../in/JwtAuthenticationFilterUsecase.java | 8 ++ .../port/in/ResolveTokenUsecase.java | 7 + .../port/in/SocialLoginUsecase.java | 7 + .../port/in/ValidateTokenUsecase.java | 5 + .../JwtAuthenticationFilterService.java} | 24 ++-- .../syncd/application/service/JwtService.java | 129 ++++++++++++++++++ .../application/service/LoginService.java | 10 +- .../adaptor/in/web/UserControllerTest.java | 63 +++++++++ .../project/ReadProjectPortTest.java | 3 +- 21 files changed, 310 insertions(+), 199 deletions(-) delete mode 100644 src/main/java/com/syncd/adapter/in/oauth/JwtTokenProvider.java delete mode 100644 src/main/java/com/syncd/adapter/in/oauth/JwtUtils.java create mode 100644 src/main/java/com/syncd/application/port/in/GenerateTokenUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/GetUserIdFromTokenUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/GetUsernameFromTokenUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/JwtAuthenticationFilterUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/ResolveTokenUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java create mode 100644 src/main/java/com/syncd/application/port/in/ValidateTokenUsecase.java rename src/main/java/com/syncd/{adapter/in/oauth/JwtAuthenticationFilter.java => application/service/JwtAuthenticationFilterService.java} (53%) create mode 100644 src/main/java/com/syncd/application/service/JwtService.java create mode 100644 src/test/java/adaptor/in/web/UserControllerTest.java diff --git a/src/main/java/com/syncd/adapter/in/oauth/JwtTokenProvider.java b/src/main/java/com/syncd/adapter/in/oauth/JwtTokenProvider.java deleted file mode 100644 index a8c0710..0000000 --- a/src/main/java/com/syncd/adapter/in/oauth/JwtTokenProvider.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.syncd.adapter.in.oauth; -import com.syncd.application.port.out.persistence.user.ReadUserPort; -import com.syncd.domain.user.User; -import io.jsonwebtoken.*; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; - -import java.util.Collections; -import java.util.Date; - -@Component -@RequiredArgsConstructor -public class JwtTokenProvider { - - private final ReadUserPort readUserPort; - @Value("${spring.jwt.secret}") - private String secretKey; - - @Value("${spring.jwt.expiration}") - private long validityInMilliseconds; - - public String createToken(String username) { - Date now = new Date(); - Date validity = new Date(now.getTime() + validityInMilliseconds); - - return Jwts.builder() - .setSubject(username) - .setIssuedAt(now) - .setExpiration(validity) - .signWith(SignatureAlgorithm.HS256, secretKey) - .compact(); - } - - public String getUsernameFromToken(String token) { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); - } - - public boolean validateToken(String token) { - try { - Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - - -// public UsernamePasswordAuthenticationToken getAuthentication(String token) { -// UserDetails userDetails = this.userDetailsService.loadUserByUsername(getUsernameFromToken(token)); -// return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); -// } - - - public String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); // "Bearer " 다음의 토큰 부분만 추출 - } - return null; - } - - - public String getUserIdFromToken(String token) { - Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); - return claims.get("id", String.class); - } - -// public UsernamePasswordAuthenticationToken getAuthentication(String token) { -// String username = getUsernameFromToken(token); -// User user = readUserPort.findByUsername(username); -// -// // UserDetails의 구현체로 변환 -// UserDetails userDetails = new User(); -// -// return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); -// } -} - diff --git a/src/main/java/com/syncd/adapter/in/oauth/JwtUtils.java b/src/main/java/com/syncd/adapter/in/oauth/JwtUtils.java deleted file mode 100644 index 696c66b..0000000 --- a/src/main/java/com/syncd/adapter/in/oauth/JwtUtils.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.syncd.adapter.in.oauth; - -import com.syncd.domain.user.User; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.beans.factory.annotation.Value; - -import javax.crypto.spec.SecretKeySpec; -import java.security.Key; -import java.util.*; - -public class JwtUtils { - - @Value("${spring.jwt.secret}") - private static String accessTokenSecretKey; - - public static String generateToken(User user) { - - return Jwts.builder() - .setHeader(createHeader()) - .setClaims(createClaims(user)) - .setIssuedAt(new Date()) - .setExpiration(createExpireDateForAccessToken()) - .signWith(SignatureAlgorithm.HS256, createSigningKey()) - .compact(); - - } - - private static Map createHeader() { - Map header = new HashMap<>(); - header.put("typ", "JWT"); - header.put("alg", "HS256"); - return header; - } - - private static Map createClaims(User user) { - Map claims = new HashMap<>(); - claims.put("id", user.getId()); - claims.put("email", user.getEmail()); - claims.put("name", user.getName()); - claims.put("img", user.getProfileImg()); - return claims; - } - - private static Date createExpireDateForAccessToken() { - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.SECOND, 60000 * 10); - return calendar.getTime(); - } - - private static Key createSigningKey() { - byte[] keyBytes = Base64.getDecoder().decode("aS1kZWFyLXN5bmNkLXNlY3JldC1rZXktaS1kZWFyLXN5bmNkLXNlY3JldC1rZXk="); - return new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName()); - } -} diff --git a/src/main/java/com/syncd/adapter/in/web/AuthController.java b/src/main/java/com/syncd/adapter/in/web/AuthController.java index cb4d06e..2599090 100644 --- a/src/main/java/com/syncd/adapter/in/web/AuthController.java +++ b/src/main/java/com/syncd/adapter/in/web/AuthController.java @@ -1,6 +1,7 @@ package com.syncd.adapter.in.web; import com.syncd.AuthControllerProperties; +import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.service.LoginService; import com.syncd.dto.TokenDto; @@ -15,13 +16,13 @@ @RequiredArgsConstructor @RequestMapping("/login/oauth2") public class AuthController { - private final LoginService loginService; + private final SocialLoginUsecase socialLoginUsecase; private final AuthControllerProperties authControllerProperties; @GetMapping("/code/{registrationId}") public RedirectView googleLogin(@RequestParam String code, @PathVariable String registrationId, HttpServletResponse response) { String url = authControllerProperties.getRedirectUrl(); - TokenDto token = loginService.socialLogin(code, registrationId); + TokenDto token = socialLoginUsecase.socialLogin(code, registrationId); String redirectUrl = url + token.accessToken(); return new RedirectView(redirectUrl); } diff --git a/src/main/java/com/syncd/adapter/in/web/LoginController.java b/src/main/java/com/syncd/adapter/in/web/LoginController.java index 3108598..1849922 100644 --- a/src/main/java/com/syncd/adapter/in/web/LoginController.java +++ b/src/main/java/com/syncd/adapter/in/web/LoginController.java @@ -1,6 +1,7 @@ package com.syncd.adapter.in.web; import com.syncd.GoogleOAuth2Properties; +import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.service.LoginService; import com.syncd.dto.TokenDto; @@ -14,8 +15,6 @@ @RequiredArgsConstructor @RequestMapping("/v1/auth") public class LoginController { - private final ReadUserPort readUserPort; - private final LoginService loginService; private final GoogleOAuth2Properties googleOAuth2Properties; @GetMapping("/login/google") diff --git a/src/main/java/com/syncd/adapter/in/web/ProjectController.java b/src/main/java/com/syncd/adapter/in/web/ProjectController.java index d08c67e..c1b478e 100644 --- a/src/main/java/com/syncd/adapter/in/web/ProjectController.java +++ b/src/main/java/com/syncd/adapter/in/web/ProjectController.java @@ -1,6 +1,6 @@ package com.syncd.adapter.in.web; -import com.syncd.adapter.in.oauth.JwtTokenProvider; +import com.syncd.application.service.JwtService; import com.syncd.application.port.in.*; import com.syncd.application.port.in.CreateProjectUsecase.*; import com.syncd.application.port.in.JoinProjectUsecase.*; @@ -17,9 +17,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; @RestController @RequiredArgsConstructor @@ -41,55 +38,55 @@ public class ProjectController { private final SyncProjectUsecase syncProjectUsecase; private final MakeUserstoryUsecase makeUserstoryUsecase; - private final JwtTokenProvider jwtTokenProvider; + private final JwtService jwtService; @PostMapping(value = "/create") public CreateProjectResponseDto createProject(HttpServletRequest request, @Valid @ModelAttribute CreateProjectRequestDto requestDto) { - String token = jwtTokenProvider.resolveToken(request); - return createProjectUsecase.createProject(jwtTokenProvider.getUserIdFromToken(token), jwtTokenProvider.getUsernameFromToken(token), requestDto.name(), requestDto.description(), requestDto.img(), requestDto.userEmails()); + String token = jwtService.resolveToken(request); + return createProjectUsecase.createProject(jwtService.getUserIdFromToken(token), jwtService.getUsernameFromToken(token), requestDto.name(), requestDto.description(), requestDto.img(), requestDto.userEmails()); } @PostMapping("/join") public JoinProjectResponseDto joinProject(HttpServletRequest request, @Valid @RequestBody JoinProjectRequestDto requestDto) { - String token = jwtTokenProvider.resolveToken(request); - return joinProjectUsecase.joinProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId()); + String token = jwtService.resolveToken(request); + return joinProjectUsecase.joinProject(jwtService.getUserIdFromToken(token), requestDto.projectId()); } @PostMapping("/invite") public InviteUserInProjectResponseDto inviteUser(HttpServletRequest request, @Valid @RequestBody InviteUserInProjectRequestDto requestDto){ - String token = jwtTokenProvider.resolveToken(request); - return inviteUserInProjectUsecase.inviteUserInProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId(), requestDto.users()); + String token = jwtService.resolveToken(request); + return inviteUserInProjectUsecase.inviteUserInProject(jwtService.getUserIdFromToken(token), requestDto.projectId(), requestDto.users()); } @PostMapping("/withdraw") public WithdrawUserInProjectResponseDto withdrawUser(HttpServletRequest request,@Valid @RequestBody WithdrawUserInProjectRequestDto requestDto){ - String token = jwtTokenProvider.resolveToken(request); - return withdrawUserInProjectUsecase.withdrawUserInProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId(), requestDto.users()); + String token = jwtService.resolveToken(request); + return withdrawUserInProjectUsecase.withdrawUserInProject(jwtService.getUserIdFromToken(token), requestDto.projectId(), requestDto.users()); } @PostMapping("/delete") public DeleteProjectResponseDto deleteProject(HttpServletRequest request,@Valid @RequestBody DeleteProjectRequestDto requestDto){ - String token = jwtTokenProvider.resolveToken(request); - return deleteProjectUsecase.deleteProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId()); + String token = jwtService.resolveToken(request); + return deleteProjectUsecase.deleteProject(jwtService.getUserIdFromToken(token), requestDto.projectId()); } @PostMapping("/update") public UpdateProjectResponseDto updateProject(HttpServletRequest request, @Valid @RequestBody UpdateProjectRequestDto requestDto){ - String token = jwtTokenProvider.resolveToken(request); - return updateProjectUsecase.updateProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId(), requestDto.projectName(), requestDto.description(), requestDto.image()); + String token = jwtService.resolveToken(request); + return updateProjectUsecase.updateProject(jwtService.getUserIdFromToken(token), requestDto.projectId(), requestDto.projectName(), requestDto.description(), requestDto.image()); } @PostMapping("/sync") public SyncProjectResponseDto syncProject(HttpServletRequest request, @Valid @RequestBody SyncProjectRequestDto requestDto){ - String token = jwtTokenProvider.resolveToken(request); - return syncProjectUsecase.syncProject(jwtTokenProvider.getUserIdFromToken(token), requestDto.projectId(), requestDto.projectStage()); + String token = jwtService.resolveToken(request); + return syncProjectUsecase.syncProject(jwtService.getUserIdFromToken(token), requestDto.projectId(), requestDto.projectStage()); } @PostMapping("/userstory") public ResponseEntity makeUserStory(HttpServletRequest request, @RequestBody MakeUserStoryReauestDto makeUserStoryReauestDto) { - String token = jwtTokenProvider.resolveToken(request); - MakeUserStoryResponseDto result = makeUserstoryUsecase.makeUserstory(jwtTokenProvider.getUserIdFromToken(token), makeUserStoryReauestDto.getProjectId(), makeUserStoryReauestDto.getScenario()); + String token = jwtService.resolveToken(request); + MakeUserStoryResponseDto result = makeUserstoryUsecase.makeUserstory(jwtService.getUserIdFromToken(token), makeUserStoryReauestDto.getProjectId(), makeUserStoryReauestDto.getScenario()); return new ResponseEntity<>(result, HttpStatus.OK); } diff --git a/src/main/java/com/syncd/adapter/in/web/RoomController.java b/src/main/java/com/syncd/adapter/in/web/RoomController.java index 4319c62..3901403 100644 --- a/src/main/java/com/syncd/adapter/in/web/RoomController.java +++ b/src/main/java/com/syncd/adapter/in/web/RoomController.java @@ -1,6 +1,6 @@ package com.syncd.adapter.in.web; -import com.syncd.adapter.in.oauth.JwtTokenProvider; +import com.syncd.application.service.JwtService; import com.syncd.application.port.in.GetAllRoomsByUserIdUsecase; import com.syncd.application.port.in.GetAllRoomsByUserIdUsecase.*; import com.syncd.application.port.in.GetRoomAuthTokenUsecase; @@ -17,23 +17,23 @@ public class RoomController { private final GetAllRoomsByUserIdUsecase getAllRoomsByUserIdUsecase; private final GetRoomAuthTokenUsecase getRoomAuthTokenUsecase; - private final JwtTokenProvider jwtTokenProvider; + private final JwtService jwtService; @GetMapping("/auth") public GetRoomAuthTokenResponseDto getRoomAuthToken(HttpServletRequest request){ - String token = jwtTokenProvider.resolveToken(request); - return getRoomAuthTokenUsecase.getRoomAuthToken(jwtTokenProvider.getUserIdFromToken(token)); + String token = jwtService.resolveToken(request); + return getRoomAuthTokenUsecase.getRoomAuthToken(jwtService.getUserIdFromToken(token)); } @GetMapping("") public GetAllRoomsByUserIdResponseDto getAllInfoAboutRoomsByUserId(HttpServletRequest request){ - String token = jwtTokenProvider.resolveToken(request); - return getAllRoomsByUserIdUsecase.getAllRoomsByUserId(jwtTokenProvider.getUserIdFromToken(token)); + String token = jwtService.resolveToken(request); + return getAllRoomsByUserIdUsecase.getAllRoomsByUserId(jwtService.getUserIdFromToken(token)); } @PostMapping("/test-auth") public GetRoomAuthTokenResponseDto getRoomAuthToken(@RequestBody @Valid GetRoomAuthTokenUsecase.TestDto getRoomAuthToken, HttpServletRequest request){ - String token = jwtTokenProvider.resolveToken(request); - return getRoomAuthTokenUsecase.Test(jwtTokenProvider.getUserIdFromToken(token),getRoomAuthToken.roomId()); + String token = jwtService.resolveToken(request); + return getRoomAuthTokenUsecase.Test(jwtService.getUserIdFromToken(token),getRoomAuthToken.roomId()); } } diff --git a/src/main/java/com/syncd/adapter/in/web/UserController.java b/src/main/java/com/syncd/adapter/in/web/UserController.java index 4e103c5..1927c1c 100644 --- a/src/main/java/com/syncd/adapter/in/web/UserController.java +++ b/src/main/java/com/syncd/adapter/in/web/UserController.java @@ -4,14 +4,14 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import com.syncd.adapter.in.oauth.JwtTokenProvider; +import com.syncd.application.service.JwtService; @RestController @RequiredArgsConstructor @RequestMapping("/v1/user") public class UserController { private final GetUserInfoUsecase getUserInfoUsecase; - private final JwtTokenProvider jwtTokenProvider; + private final JwtService jwtService; // @PostMapping("/register") // public RegisterUserResponseDto registerUser(@RequestBody RegisterUserRequestDto requestDto){ @@ -20,8 +20,8 @@ public class UserController { @GetMapping("/info") public GetUserInfoUsecase.GetUserInfoResponseDto getUserInfo(HttpServletRequest request){ - String token = jwtTokenProvider.resolveToken(request); - return getUserInfoUsecase.getUserInfo(jwtTokenProvider.getUserIdFromToken(token)); + String token = jwtService.resolveToken(request); + return getUserInfoUsecase.getUserInfo(jwtService.getUserIdFromToken(token)); } } diff --git a/src/main/java/com/syncd/adapter/in/web/WebConfig.java b/src/main/java/com/syncd/adapter/in/web/WebConfig.java index 9f8e1e6..c2afa53 100644 --- a/src/main/java/com/syncd/adapter/in/web/WebConfig.java +++ b/src/main/java/com/syncd/adapter/in/web/WebConfig.java @@ -14,5 +14,4 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(3000); } - } diff --git a/src/main/java/com/syncd/adapter/in/web/WebSecurityConfig.java b/src/main/java/com/syncd/adapter/in/web/WebSecurityConfig.java index 564826c..1feb5f2 100644 --- a/src/main/java/com/syncd/adapter/in/web/WebSecurityConfig.java +++ b/src/main/java/com/syncd/adapter/in/web/WebSecurityConfig.java @@ -1,7 +1,10 @@ package com.syncd.adapter.in.web; -import com.syncd.adapter.in.oauth.JwtAuthenticationFilter; -import com.syncd.adapter.in.oauth.JwtTokenProvider; +import com.syncd.application.port.in.JwtAuthenticationFilterUsecase; +import com.syncd.application.port.in.ResolveTokenUsecase; +import com.syncd.application.port.in.ValidateTokenUsecase; +import com.syncd.application.service.JwtService; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -9,22 +12,23 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class WebSecurityConfig{ - private final JwtTokenProvider jwtTokenProvider; + private final ResolveTokenUsecase resolveTokenUsecase; + + private final ValidateTokenUsecase validateTokenUsecase; + + private final JwtAuthenticationFilterUsecase jwtAuthenticationFilterUsecase; - @Autowired - public WebSecurityConfig(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilterUsecase.JwtAuthenticationFilter(resolveTokenUsecase,validateTokenUsecase), UsernamePasswordAuthenticationFilter.class) .csrf(csrf -> csrf.disable()); return http.build(); } diff --git a/src/main/java/com/syncd/application/port/in/GenerateTokenUsecase.java b/src/main/java/com/syncd/application/port/in/GenerateTokenUsecase.java new file mode 100644 index 0000000..0607ea6 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/GenerateTokenUsecase.java @@ -0,0 +1,7 @@ +package com.syncd.application.port.in; + +import com.syncd.domain.user.User; + +public interface GenerateTokenUsecase { + String generateToken(User user); +} diff --git a/src/main/java/com/syncd/application/port/in/GetUserIdFromTokenUsecase.java b/src/main/java/com/syncd/application/port/in/GetUserIdFromTokenUsecase.java new file mode 100644 index 0000000..6deb7dc --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/GetUserIdFromTokenUsecase.java @@ -0,0 +1,7 @@ +package com.syncd.application.port.in; + +import jakarta.servlet.http.HttpServletRequest; + +public interface GetUserIdFromTokenUsecase { + String getUserIdFromToken(String token); +} diff --git a/src/main/java/com/syncd/application/port/in/GetUsernameFromTokenUsecase.java b/src/main/java/com/syncd/application/port/in/GetUsernameFromTokenUsecase.java new file mode 100644 index 0000000..fa75e62 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/GetUsernameFromTokenUsecase.java @@ -0,0 +1,7 @@ +package com.syncd.application.port.in; + +import com.syncd.domain.user.User; + +public interface GetUsernameFromTokenUsecase { + String getUsernameFromToken(String token); +} diff --git a/src/main/java/com/syncd/application/port/in/JwtAuthenticationFilterUsecase.java b/src/main/java/com/syncd/application/port/in/JwtAuthenticationFilterUsecase.java new file mode 100644 index 0000000..b660002 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/JwtAuthenticationFilterUsecase.java @@ -0,0 +1,8 @@ +package com.syncd.application.port.in; + +import com.syncd.application.service.JwtService; +import com.syncd.domain.user.User; + +public interface JwtAuthenticationFilterUsecase { + jakarta.servlet.Filter JwtAuthenticationFilter(ResolveTokenUsecase resolveTokenUsecase, ValidateTokenUsecase validateTokenUsecase); +} diff --git a/src/main/java/com/syncd/application/port/in/ResolveTokenUsecase.java b/src/main/java/com/syncd/application/port/in/ResolveTokenUsecase.java new file mode 100644 index 0000000..95aa945 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/ResolveTokenUsecase.java @@ -0,0 +1,7 @@ +package com.syncd.application.port.in; + +import jakarta.servlet.http.HttpServletRequest; + +public interface ResolveTokenUsecase { + String resolveToken(HttpServletRequest request); +} diff --git a/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java new file mode 100644 index 0000000..8d1f5fa --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java @@ -0,0 +1,7 @@ +package com.syncd.application.port.in; + +import com.syncd.dto.TokenDto; + +public interface SocialLoginUsecase { + TokenDto socialLogin(String code, String registrationId); +} diff --git a/src/main/java/com/syncd/application/port/in/ValidateTokenUsecase.java b/src/main/java/com/syncd/application/port/in/ValidateTokenUsecase.java new file mode 100644 index 0000000..e752a63 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/ValidateTokenUsecase.java @@ -0,0 +1,5 @@ +package com.syncd.application.port.in; + +public interface ValidateTokenUsecase { + boolean validateToken(String token); +} diff --git a/src/main/java/com/syncd/adapter/in/oauth/JwtAuthenticationFilter.java b/src/main/java/com/syncd/application/service/JwtAuthenticationFilterService.java similarity index 53% rename from src/main/java/com/syncd/adapter/in/oauth/JwtAuthenticationFilter.java rename to src/main/java/com/syncd/application/service/JwtAuthenticationFilterService.java index 112e0c1..ce4a144 100644 --- a/src/main/java/com/syncd/adapter/in/oauth/JwtAuthenticationFilter.java +++ b/src/main/java/com/syncd/application/service/JwtAuthenticationFilterService.java @@ -1,27 +1,31 @@ -package com.syncd.adapter.in.oauth; +package com.syncd.application.service; +import com.syncd.application.port.in.ResolveTokenUsecase; +import com.syncd.application.port.in.ValidateTokenUsecase; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -public class JwtAuthenticationFilter extends OncePerRequestFilter { +@Service +@Primary +@RequiredArgsConstructor +public class JwtAuthenticationFilterService extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; + private final ResolveTokenUsecase resolveTokenUsecase; - public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } + private final ValidateTokenUsecase validateTokenUsecase; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = jwtTokenProvider.resolveToken(request); - if (token != null && jwtTokenProvider.validateToken(token)) { + String token = resolveTokenUsecase.resolveToken(request); + if (token != null && validateTokenUsecase.validateToken(token)) { // 토큰이 유효한 경우, 사용자 인증 정보를 설정합니다. // UsernamePasswordAuthenticationToken auth = jwtTokenProvider.getAuthentication(token); // SecurityContextHolder.getContext().setAuthentication(auth); diff --git a/src/main/java/com/syncd/application/service/JwtService.java b/src/main/java/com/syncd/application/service/JwtService.java new file mode 100644 index 0000000..e0e93a8 --- /dev/null +++ b/src/main/java/com/syncd/application/service/JwtService.java @@ -0,0 +1,129 @@ +package com.syncd.application.service; +import com.syncd.application.port.in.*; +import com.syncd.domain.user.User; +import io.jsonwebtoken.*; +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.*; + +@Service +@Primary +@RequiredArgsConstructor +public class JwtService implements GenerateTokenUsecase, JwtAuthenticationFilterUsecase, GetUserIdFromTokenUsecase, GetUsernameFromTokenUsecase, ResolveTokenUsecase, ValidateTokenUsecase { + + @Value("${spring.jwt.secret}") + private String secretKey; + + @Value("${spring.jwt.expiration}") + private long validityInMilliseconds; + @Value("${spring.jwt.secret}") + private static String accessTokenSecretKey; + + @Override + public String generateToken(User user) { + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(createClaims(user)) + .setIssuedAt(new Date()) + .setExpiration(createExpireDateForAccessToken()) + .signWith(SignatureAlgorithm.HS256, createSigningKey()) + .compact(); + } + + private static Map createHeader() { + Map header = new HashMap<>(); + header.put("typ", "JWT"); + header.put("alg", "HS256"); + return header; + } + + private static Map createClaims(User user) { + Map claims = new HashMap<>(); + claims.put("id", user.getId()); + claims.put("email", user.getEmail()); + claims.put("name", user.getName()); + claims.put("img", user.getProfileImg()); + return claims; + } + + private static Date createExpireDateForAccessToken() { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.SECOND, 60000 * 10); + return calendar.getTime(); + } + + private static Key createSigningKey() { + byte[] keyBytes = Base64.getDecoder().decode("aS1kZWFyLXN5bmNkLXNlY3JldC1rZXktaS1kZWFyLXN5bmNkLXNlY3JldC1rZXk="); + return new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName()); + } + +// public String createToken(String username) { +// Date now = new Date(); +// Date validity = new Date(now.getTime() + validityInMilliseconds); +// +// return Jwts.builder() +// .setSubject(username) +// .setIssuedAt(now) +// .setExpiration(validity) +// .signWith(SignatureAlgorithm.HS256, secretKey) +// .compact(); +// } + @Override + public String getUsernameFromToken(String token) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + } + @Override + public boolean validateToken(String token) { + try { + Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + +// public UsernamePasswordAuthenticationToken getAuthentication(String token) { +// UserDetails userDetails = this.userDetailsService.loadUserByUsername(getUsernameFromToken(token)); +// return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); +// } + + @Override + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // "Bearer " 다음의 토큰 부분만 추출 + } + return null; + } + + @Override + public String getUserIdFromToken(String token) { + Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); + return claims.get("id", String.class); + } + + @Override + public Filter JwtAuthenticationFilter(ResolveTokenUsecase resolveTokenUsecase, ValidateTokenUsecase validateTokenUsecase) { + return new JwtAuthenticationFilterService(resolveTokenUsecase,validateTokenUsecase); + } + +// public UsernamePasswordAuthenticationToken getAuthentication(String token) { +// String username = getUsernameFromToken(token); +// User user = readUserPort.findByUsername(username); +// +// // UserDetails의 구현체로 변환 +// UserDetails userDetails = new User(); +// +// return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); +// } +} + diff --git a/src/main/java/com/syncd/application/service/LoginService.java b/src/main/java/com/syncd/application/service/LoginService.java index 1d78bd1..554cea4 100644 --- a/src/main/java/com/syncd/application/service/LoginService.java +++ b/src/main/java/com/syncd/application/service/LoginService.java @@ -1,7 +1,8 @@ package com.syncd.application.service; import com.fasterxml.jackson.databind.JsonNode; -import com.syncd.adapter.in.oauth.JwtUtils; +import com.syncd.application.port.in.GenerateTokenUsecase; +import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.port.out.persistence.user.WriteUserPort; import com.syncd.domain.user.User; @@ -18,7 +19,7 @@ @Service @Primary @RequiredArgsConstructor -public class LoginService { +public class LoginService implements SocialLoginUsecase { private final RestTemplate restTemplate; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String clientId ; @@ -32,7 +33,10 @@ public class LoginService { private String resourceUri; private final WriteUserPort writeUserPort; + private final GenerateTokenUsecase generateTokenUsecase; + private final ReadUserPort readUserPort; + @Override public TokenDto socialLogin(String code, String registrationId) { String googleAccessToken = getAccessToken(code, registrationId); JsonNode userResourceNode = getUserResource(googleAccessToken, registrationId); @@ -55,7 +59,7 @@ public TokenDto socialLogin(String code, String registrationId) { user.setId(readUserPort.findByEmail(userEmail).getId()); } - String accessToken = JwtUtils.generateToken(user); + String accessToken = generateTokenUsecase.generateToken(user); System.out.println("JWT accessToken : " + accessToken); return new TokenDto(accessToken,""); } diff --git a/src/test/java/adaptor/in/web/UserControllerTest.java b/src/test/java/adaptor/in/web/UserControllerTest.java new file mode 100644 index 0000000..383431e --- /dev/null +++ b/src/test/java/adaptor/in/web/UserControllerTest.java @@ -0,0 +1,63 @@ +package adaptor.in.web; + +import com.syncd.application.service.JwtService; +import com.syncd.adapter.in.web.UserController; +import com.syncd.application.port.in.GetUserInfoUsecase; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MockGetUserInfoUsecase implements GetUserInfoUsecase{ + + @Override + public GetUserInfoResponseDto getUserInfo(String userId) { + return null; + } +} + +class UserControllerTest { + + private UserController userController; + + private GetUserInfoUsecase getUserInfoUsecase = new MockGetUserInfoUsecase(); + + @Mock + private JwtService jwtService; + + @Mock + private HttpServletRequest request; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + userController = new UserController(getUserInfoUsecase, jwtService); + } + + @Test + void getUserInfo_ReturnsUserInfo_WhenValidTokenProvided() { + // Arrange + String token = "valid_token"; + String userId = "user123"; + when(jwtService.resolveToken(request)).thenReturn(token); + when(jwtService.getUserIdFromToken(token)).thenReturn(userId); + + GetUserInfoUsecase.GetUserInfoResponseDto expectedUserInfo = new GetUserInfoUsecase.GetUserInfoResponseDto(null,null,null,null,null); + // Assuming you have some mock data or create an instance here + + when(getUserInfoUsecase.getUserInfo(userId)).thenReturn(expectedUserInfo); + + // Act + GetUserInfoUsecase.GetUserInfoResponseDto actualUserInfo = userController.getUserInfo(request); + + // Assert + assertEquals(expectedUserInfo, actualUserInfo); + verify(jwtService).resolveToken(request); + verify(jwtService).getUserIdFromToken(token); + verify(getUserInfoUsecase).getUserInfo(userId); + } +} \ No newline at end of file diff --git a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java index cabff42..e980773 100644 --- a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java +++ b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java @@ -1,5 +1,6 @@ -package com.syncd.application.port.out.persistence.project; +package application.port.out.persistence.project; +import com.syncd.application.port.out.persistence.project.ReadProjectPort; import com.syncd.domain.project.Project; import com.syncd.domain.user.User; import org.junit.jupiter.api.BeforeEach; From 61eef01f3fe52821a6dd468eda3739873c9b08a6 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 19 May 2024 16:48:55 +0900 Subject: [PATCH 13/21] fix: Redirect --- .../syncd/adapter/in/web/AuthController.java | 39 ++++++++++++++++++- .../syncd/adapter/in/web/LoginController.java | 8 +--- .../application/service/LoginService.java | 1 - .../service/ProjectServiceTest.java | 2 - 4 files changed, 39 insertions(+), 11 deletions(-) delete mode 100644 src/test/java/application/service/ProjectServiceTest.java diff --git a/src/main/java/com/syncd/adapter/in/web/AuthController.java b/src/main/java/com/syncd/adapter/in/web/AuthController.java index 2599090..383f194 100644 --- a/src/main/java/com/syncd/adapter/in/web/AuthController.java +++ b/src/main/java/com/syncd/adapter/in/web/AuthController.java @@ -5,6 +5,8 @@ import com.syncd.application.service.LoginService; import com.syncd.dto.TokenDto; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,6 +14,9 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; +import java.util.Collection; +import java.util.Enumeration; + @RestController @RequiredArgsConstructor @RequestMapping("/login/oauth2") @@ -20,10 +25,40 @@ public class AuthController { private final AuthControllerProperties authControllerProperties; @GetMapping("/code/{registrationId}") - public RedirectView googleLogin(@RequestParam String code, @PathVariable String registrationId, HttpServletResponse response) { + public RedirectView googleLogin(@RequestParam String code, + @PathVariable String registrationId, + HttpServletRequest request, + HttpServletResponse response) { String url = authControllerProperties.getRedirectUrl(); TokenDto token = socialLoginUsecase.socialLogin(code, registrationId); - String redirectUrl = url + token.accessToken(); + + Enumeration reqHeaderNames = request.getHeaderNames(); + + while (reqHeaderNames.hasMoreElements()) { + String headerName = reqHeaderNames.nextElement(); + String headerValue = request.getHeader(headerName); + System.out.println(headerName + ": " + headerValue); + } + String cookies = request.getHeader("cookie"); + String refererSubstring=""; + int refererIndex = cookies.indexOf("referer="); + if (refererIndex != -1) { + // 'referer=' 이후의 부분 추출 + refererSubstring = cookies.substring(refererIndex + "referer=".length()); + + // ';' 이전의 부분 추출 (쿠키가 끝날 때까지) + int semicolonIndex = refererSubstring.indexOf(';'); + if (semicolonIndex != -1) { + refererSubstring = refererSubstring.substring(0, semicolonIndex); + } + + System.out.println("Referer: " + refererSubstring); + } else { + System.out.println("Referer not found."); + refererSubstring = "https://syncd.i-dear.org/"; + } + + String redirectUrl = refererSubstring + url + token.accessToken(); return new RedirectView(redirectUrl); } } diff --git a/src/main/java/com/syncd/adapter/in/web/LoginController.java b/src/main/java/com/syncd/adapter/in/web/LoginController.java index 1849922..635e559 100644 --- a/src/main/java/com/syncd/adapter/in/web/LoginController.java +++ b/src/main/java/com/syncd/adapter/in/web/LoginController.java @@ -20,17 +20,13 @@ public class LoginController { @GetMapping("/login/google") public RedirectView redirectToGoogleOAuth(HttpServletRequest request) { String redirectUrl = googleOAuth2Properties.getRedirectUri(); - String targetUrl = request.getHeader("Referer")+"login/oauth2/code/google"; - if (targetUrl == null || targetUrl.isBlank()) { - // 기본 URL 설정 - targetUrl = redirectUrl; - } - System.out.println(targetUrl); + String targetUrl = redirectUrl; String url = "https://accounts.google.com/o/oauth2/auth" + "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + "&redirect_uri=" + targetUrl + "&response_type=code" + "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; + System.out.println(url); return new RedirectView(url); } } \ No newline at end of file diff --git a/src/main/java/com/syncd/application/service/LoginService.java b/src/main/java/com/syncd/application/service/LoginService.java index 554cea4..5e8285b 100644 --- a/src/main/java/com/syncd/application/service/LoginService.java +++ b/src/main/java/com/syncd/application/service/LoginService.java @@ -66,7 +66,6 @@ public TokenDto socialLogin(String code, String registrationId) { private String getAccessToken(String authorizationCode, String registrationId) { - MultiValueMap params = new LinkedMultiValueMap<>(); params.add("code", authorizationCode); params.add("client_id", clientId); diff --git a/src/test/java/application/service/ProjectServiceTest.java b/src/test/java/application/service/ProjectServiceTest.java deleted file mode 100644 index 2b0e9d2..0000000 --- a/src/test/java/application/service/ProjectServiceTest.java +++ /dev/null @@ -1,2 +0,0 @@ -package application.service;public class ProjectServiceTest { -} From ae9a74e23514bbfa1a94f8401b03fdb6c968df90 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 19 May 2024 17:11:34 +0900 Subject: [PATCH 14/21] fix: Fix error --- .../syncd/adapter/in/web/AuthController.java | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/syncd/adapter/in/web/AuthController.java b/src/main/java/com/syncd/adapter/in/web/AuthController.java index 383f194..516a361 100644 --- a/src/main/java/com/syncd/adapter/in/web/AuthController.java +++ b/src/main/java/com/syncd/adapter/in/web/AuthController.java @@ -40,19 +40,25 @@ public RedirectView googleLogin(@RequestParam String code, System.out.println(headerName + ": " + headerValue); } String cookies = request.getHeader("cookie"); + String referer = request.getHeader("referer"); String refererSubstring=""; - int refererIndex = cookies.indexOf("referer="); - if (refererIndex != -1) { - // 'referer=' 이후의 부분 추출 - refererSubstring = cookies.substring(refererIndex + "referer=".length()); + if(cookies!=null){ + int refererIndex = cookies.indexOf("referer="); + if (refererIndex != -1) { + // 'referer=' 이후의 부분 추출 + refererSubstring = cookies.substring(refererIndex + "referer=".length()); - // ';' 이전의 부분 추출 (쿠키가 끝날 때까지) - int semicolonIndex = refererSubstring.indexOf(';'); - if (semicolonIndex != -1) { - refererSubstring = refererSubstring.substring(0, semicolonIndex); - } + // ';' 이전의 부분 추출 (쿠키가 끝날 때까지) + int semicolonIndex = refererSubstring.indexOf(';'); + if (semicolonIndex != -1) { + refererSubstring = refererSubstring.substring(0, semicolonIndex); + } - System.out.println("Referer: " + refererSubstring); + System.out.println("Referer: " + refererSubstring); + } + } else if (referer!=null) { + System.out.println("Referer from referer"); + refererSubstring=referer; } else { System.out.println("Referer not found."); refererSubstring = "https://syncd.i-dear.org/"; From a9fe5336801c36172a780c41925b6674bb0e2926 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 19 May 2024 21:34:44 +0900 Subject: [PATCH 15/21] feat: Fix redirect uri --- .../com/syncd/AuthControllerProperties.java | 22 ++++++++ .../syncd/adapter/in/web/AuthController.java | 52 ++++++------------- .../syncd/adapter/in/web/LoginController.java | 46 +++++++++++++--- .../port/in/CreateProjectUsecase.java | 10 +--- .../port/in/GetOauthRedirectUrlUsecase.java | 15 ++++++ .../port/in/SocialLoginUsecase.java | 2 +- .../application/service/LoginService.java | 43 +++++++++++---- .../application/service/ProjectService.java | 9 ++-- .../com/syncd/domain/project/Project.java | 10 ++-- .../ProjectPersistenceAdapterTest.java | 2 +- .../project/ReadProjectPortTest.java | 2 +- .../project/WriteProjectPortTest.java | 3 +- src/test/java/domain/project/ProjectTest.java | 2 +- 13 files changed, 139 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/syncd/application/port/in/GetOauthRedirectUrlUsecase.java diff --git a/src/main/java/com/syncd/AuthControllerProperties.java b/src/main/java/com/syncd/AuthControllerProperties.java index 35a1aeb..c7bf7e7 100644 --- a/src/main/java/com/syncd/AuthControllerProperties.java +++ b/src/main/java/com/syncd/AuthControllerProperties.java @@ -8,11 +8,33 @@ public class AuthControllerProperties { private String redirectUrl; + private String redirectUrlDev; + + private String redirectUriForGoogle; + + private String redirectUriForGoogleDev; + + public String getRedirectUrl() { return redirectUrl; } + public String getRedirectUrlDev() { + return redirectUrlDev; + } + + public String getRedirectUriForGoogle() {return redirectUriForGoogle;} + + public String getRedirectUriForGoogleDev() {return redirectUriForGoogleDev;} + public void setRedirectUrl(String redirectUrl) { this.redirectUrl = redirectUrl; } + public void setRedirectUrlDev(String redirectUrlDev) { + this.redirectUrlDev = redirectUrlDev; + } + + public void setRedirectUriForGoogle(String redirectUriForGoogle) {this.redirectUriForGoogle = redirectUriForGoogle;} + + public void setRedirectUriForGoogleDev(String redirectUriForGoogleDev) {this.redirectUriForGoogleDev = redirectUriForGoogleDev;} } diff --git a/src/main/java/com/syncd/adapter/in/web/AuthController.java b/src/main/java/com/syncd/adapter/in/web/AuthController.java index 516a361..5522a9c 100644 --- a/src/main/java/com/syncd/adapter/in/web/AuthController.java +++ b/src/main/java/com/syncd/adapter/in/web/AuthController.java @@ -26,45 +26,23 @@ public class AuthController { @GetMapping("/code/{registrationId}") public RedirectView googleLogin(@RequestParam String code, - @PathVariable String registrationId, - HttpServletRequest request, - HttpServletResponse response) { + @PathVariable String registrationId) { + String redirectUri = authControllerProperties.getRedirectUriForGoogle(); String url = authControllerProperties.getRedirectUrl(); - TokenDto token = socialLoginUsecase.socialLogin(code, registrationId); - - Enumeration reqHeaderNames = request.getHeaderNames(); - - while (reqHeaderNames.hasMoreElements()) { - String headerName = reqHeaderNames.nextElement(); - String headerValue = request.getHeader(headerName); - System.out.println(headerName + ": " + headerValue); - } - String cookies = request.getHeader("cookie"); - String referer = request.getHeader("referer"); - String refererSubstring=""; - if(cookies!=null){ - int refererIndex = cookies.indexOf("referer="); - if (refererIndex != -1) { - // 'referer=' 이후의 부분 추출 - refererSubstring = cookies.substring(refererIndex + "referer=".length()); - - // ';' 이전의 부분 추출 (쿠키가 끝날 때까지) - int semicolonIndex = refererSubstring.indexOf(';'); - if (semicolonIndex != -1) { - refererSubstring = refererSubstring.substring(0, semicolonIndex); - } - - System.out.println("Referer: " + refererSubstring); - } - } else if (referer!=null) { - System.out.println("Referer from referer"); - refererSubstring=referer; - } else { - System.out.println("Referer not found."); - refererSubstring = "https://syncd.i-dear.org/"; - } + TokenDto token = socialLoginUsecase.socialLogin(code, registrationId,redirectUri); + + String redirectUrl = url + token.accessToken(); + return new RedirectView(redirectUrl); + } - String redirectUrl = refererSubstring + url + token.accessToken(); + @GetMapping("/code/{registrationId}/dev") + public RedirectView googleLoginDev(@RequestParam String code, + @PathVariable String registrationId) { + String url = authControllerProperties.getRedirectUrlDev(); + String redirectUri = authControllerProperties.getRedirectUriForGoogleDev(); + TokenDto token = socialLoginUsecase.socialLogin(code, registrationId,redirectUri); + String redirectUrl = url + token.accessToken(); + System.out.println("redirect: "+redirectUrl); return new RedirectView(redirectUrl); } } diff --git a/src/main/java/com/syncd/adapter/in/web/LoginController.java b/src/main/java/com/syncd/adapter/in/web/LoginController.java index 635e559..7f87a20 100644 --- a/src/main/java/com/syncd/adapter/in/web/LoginController.java +++ b/src/main/java/com/syncd/adapter/in/web/LoginController.java @@ -1,6 +1,7 @@ package com.syncd.adapter.in.web; import com.syncd.GoogleOAuth2Properties; +import com.syncd.application.port.in.GetOauthRedirectUrlUsecase; import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.service.LoginService; @@ -11,22 +12,51 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; +import java.util.Enumeration; + @RestController @RequiredArgsConstructor @RequestMapping("/v1/auth") public class LoginController { - private final GoogleOAuth2Properties googleOAuth2Properties; + private final GetOauthRedirectUrlUsecase getOauthRedirectUrlUsecase; @GetMapping("/login/google") public RedirectView redirectToGoogleOAuth(HttpServletRequest request) { - String redirectUrl = googleOAuth2Properties.getRedirectUri(); - String targetUrl = redirectUrl; - String url = "https://accounts.google.com/o/oauth2/auth" + - "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + - "&redirect_uri=" + targetUrl + - "&response_type=code" + - "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; + String referer = getReferer(request); + String url = getOauthRedirectUrlUsecase.getOauthRedirectUrlUsecase(referer); System.out.println(url); return new RedirectView(url); } + + private String getReferer(HttpServletRequest request){ + + Enumeration reqHeaderNames = request.getHeaderNames(); + while (reqHeaderNames.hasMoreElements()) { + String headerName = reqHeaderNames.nextElement(); + String headerValue = request.getHeader(headerName); + System.out.println(headerName + ": " + headerValue); + } + + String cookies = request.getHeader("cookie"); + String referer = request.getHeader("referer"); + String refererSubstring=""; + if(cookies!=null){ + int refererIndex = cookies.indexOf("referer="); + if (refererIndex != -1) { + // 'referer=' 이후의 부분 추출 + refererSubstring = cookies.substring(refererIndex + "referer=".length()); + + // ';' 이전의 부분 추출 (쿠키가 끝날 때까지) + int semicolonIndex = refererSubstring.indexOf(';'); + if (semicolonIndex != -1) { + refererSubstring = refererSubstring.substring(0, semicolonIndex); + } + } + } else if (referer!=null) { + refererSubstring=referer; + } else { + refererSubstring = "https://syncd.i-dear.org/"; + } + return refererSubstring; + } } \ No newline at end of file diff --git a/src/main/java/com/syncd/application/port/in/CreateProjectUsecase.java b/src/main/java/com/syncd/application/port/in/CreateProjectUsecase.java index 1305534..5465095 100644 --- a/src/main/java/com/syncd/application/port/in/CreateProjectUsecase.java +++ b/src/main/java/com/syncd/application/port/in/CreateProjectUsecase.java @@ -20,18 +20,12 @@ public interface CreateProjectUsecase { record CreateProjectRequestDto( @NotBlank(message = ValidationMessages.NAME_NOT_BLANK) String name, - @NotBlank(message = ValidationMessages.DESCRIPTION_NOT_BLANK) String description, MultipartFile img, - @NotNull(message = ValidationMessages.USERS_NOT_NULL) - @Size(min = 1, message = ValidationMessages.USERS_SIZE) List userEmails - ) { - } - + ) {} record CreateProjectResponseDto( String projectId - ) { - } + ) {} } \ No newline at end of file diff --git a/src/main/java/com/syncd/application/port/in/GetOauthRedirectUrlUsecase.java b/src/main/java/com/syncd/application/port/in/GetOauthRedirectUrlUsecase.java new file mode 100644 index 0000000..a315473 --- /dev/null +++ b/src/main/java/com/syncd/application/port/in/GetOauthRedirectUrlUsecase.java @@ -0,0 +1,15 @@ +package com.syncd.application.port.in; + +import com.syncd.exceptions.ValidationMessages; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface GetOauthRedirectUrlUsecase { + // ====================================== + // METHOD + // ====================================== + String getOauthRedirectUrlUsecase(String referer); + +} \ No newline at end of file diff --git a/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java index 8d1f5fa..901ae42 100644 --- a/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java +++ b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java @@ -3,5 +3,5 @@ import com.syncd.dto.TokenDto; public interface SocialLoginUsecase { - TokenDto socialLogin(String code, String registrationId); + TokenDto socialLogin(String code, String registrationId, String redirectionUri); } diff --git a/src/main/java/com/syncd/application/service/LoginService.java b/src/main/java/com/syncd/application/service/LoginService.java index 5e8285b..f5209ea 100644 --- a/src/main/java/com/syncd/application/service/LoginService.java +++ b/src/main/java/com/syncd/application/service/LoginService.java @@ -1,7 +1,9 @@ package com.syncd.application.service; import com.fasterxml.jackson.databind.JsonNode; +import com.syncd.GoogleOAuth2Properties; import com.syncd.application.port.in.GenerateTokenUsecase; +import com.syncd.application.port.in.GetOauthRedirectUrlUsecase; import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.port.out.persistence.user.WriteUserPort; @@ -19,35 +21,33 @@ @Service @Primary @RequiredArgsConstructor -public class LoginService implements SocialLoginUsecase { +public class LoginService implements SocialLoginUsecase, GetOauthRedirectUrlUsecase { private final RestTemplate restTemplate; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String clientId ; @Value("${spring.security.oauth2.client.registration.google.client-secret}") private String clientSecret; - @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") - private String redirectUri; + @Value("${spring.security.oauth2.client.provider.google.token-uri}") private String tokenUri; @Value("${spring.security.oauth2.client.provider.google.user-info-uri}") private String resourceUri; private final WriteUserPort writeUserPort; + private final GoogleOAuth2Properties googleOAuth2Properties; + private final GenerateTokenUsecase generateTokenUsecase; private final ReadUserPort readUserPort; @Override - public TokenDto socialLogin(String code, String registrationId) { - String googleAccessToken = getAccessToken(code, registrationId); + public TokenDto socialLogin(String code, String registrationId, String redirectionUri) { + String googleAccessToken = getAccessToken(code, registrationId,redirectionUri); + System.out.println("accesstoken"+googleAccessToken); JsonNode userResourceNode = getUserResource(googleAccessToken, registrationId); - System.out.println("userResourceNode = " + userResourceNode); String userEmail = userResourceNode.get("email").asText(); String userName = userResourceNode.get("name").asText(); String userProfileImg = userResourceNode.get("picture").asText(); - System.out.println("id = " + userName); - System.out.println("email = " + userEmail); - System.out.println("img = " + userProfileImg); User user = new User(); user.setName(userName); @@ -64,8 +64,8 @@ public TokenDto socialLogin(String code, String registrationId) { return new TokenDto(accessToken,""); } - private String getAccessToken(String authorizationCode, String registrationId) { - + private String getAccessToken(String authorizationCode, String registrationId, String redirectUri) { + System.out.println(redirectUri); MultiValueMap params = new LinkedMultiValueMap<>(); params.add("code", authorizationCode); params.add("client_id", clientId); @@ -90,4 +90,25 @@ private JsonNode getUserResource(String accessToken, String registrationId) { HttpEntity entity = new HttpEntity(headers); return restTemplate.exchange(resourceUri, HttpMethod.GET, entity, JsonNode.class).getBody(); } + + @Override + public String getOauthRedirectUrlUsecase(String referer) { + + String redirectUrl = googleOAuth2Properties.getRedirectUri(); + + String url = "https://accounts.google.com/o/oauth2/auth" + + "?client_id=448582571570-km2g33b06432q3ahl8pathc0tln7g0i4.apps.googleusercontent.com" + + "&redirect_uri=" + redirectUrl + + "&response_type=code" + + "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; + + if(referer.contains("localhost")){ + url = "https://accounts.google.com/o/oauth2/auth" + + "?client_id=448582571570-km2g33b06432q3ahl8pathc0tln7g0i4.apps.googleusercontent.com" + + "&redirect_uri=" + redirectUrl + "/dev"+ + "&response_type=code" + + "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; + } + return url; + } } \ No newline at end of file diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index c0ff94d..a1d4ebb 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -46,7 +46,11 @@ public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserId @Override public CreateProjectResponseDto createProject(String hostId, String hostName, String projectName, String description, MultipartFile img, List userEmails){ - List users = readUserPort.usersFromEmails(userEmails); + List users = new ArrayList<>(); + if(userEmails!=null){ + users = readUserPort.usersFromEmails(userEmails); + } + String imgURL = ""; if (img != null && !img.isEmpty()) { @@ -55,7 +59,7 @@ public CreateProjectResponseDto createProject(String hostId, String hostName, St } Project project = new Project(); - project = project.createProjectDomain(projectName, description, imgURL, hostId, null); + project = project.createProjectDomain(projectName, description, imgURL, hostId); sendMailPort.sendIviteMailBatch(hostName, projectName, users,project.getId()); return new CreateProjectResponseDto(writeProjectPort.CreateProject(project)); } @@ -128,7 +132,6 @@ public InviteUserInProjectResponseDto inviteUserInProject(String userId, String .map(email -> createUserInProjectWithRoleMember(email, host.getName(), project.getName(), projectId)) .collect(Collectors.toList()); - return new InviteUserInProjectResponseDto(projectId); } diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index 4bc07b9..a866e37 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -69,10 +69,10 @@ public void subLeftChanceForUserstory(){ // this.progress = 0; // this.lastModifiedDate = LocalDateTime.now().toString(); // } - public Project createProjectDomain(String projectName, String description, String img, String hostId, List users){ + public Project createProjectDomain(String projectName, String description, String img, String hostId){ Project project = new Project(); project.setImg(img); - project.setUsers( userInProjectsFromUsers(hostId,users)); + project.setUsers( userInProjectsFromUsers(hostId)); project.setName(projectName); project.setDescription(description); project.setProgress(0); @@ -80,10 +80,8 @@ public Project createProjectDomain(String projectName, String description, Strin project.setLeftChanceForUserstory(3); return project; } - private List userInProjectsFromUsers(String hostId, List members){ - if (members == null) { - return Collections.emptyList(); // 호스트는 존재하지만 멤버는 없을 수 있음 - } + private List userInProjectsFromUsers(String hostId){ + List members = new ArrayList<>(); return Stream.concat( Stream.of(new UserInProject(hostId, Role.HOST)), // 호스트 사용자 members.stream().map(el -> new UserInProject(el.getId(), Role.MEMBER)) diff --git a/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java b/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java index 1cb9c1e..a0dc147 100644 --- a/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java +++ b/src/test/java/adaptor/out/persistence/ProjectPersistenceAdapterTest.java @@ -39,7 +39,7 @@ void setup() { String hostId = "hostId"; List userList = new ArrayList<>(); project = new Project(); - project = project.createProjectDomain("Project Name", "Description", "img", hostId, userList); + project = project.createProjectDomain("Project Name", "Description", "img", hostId); project.setId("1"); } diff --git a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java index e980773..34a8036 100644 --- a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java +++ b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java @@ -24,7 +24,7 @@ void setUp(){ String hostId = "hostUserId"; List emptyUserList = new ArrayList<>(); project = new Project(); - project = project.createProjectDomain("Project Name", "Description", "img", hostId, emptyUserList); + project = project.createProjectDomain("Project Name", "Description", "img", hostId); project.setId("1"); project.setLastModifiedDate(LocalDateTime.now().toString()); project.setProgress(0); diff --git a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java b/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java index 37854d7..d7c2e55 100644 --- a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java +++ b/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java @@ -18,9 +18,8 @@ public class WriteProjectPortTest { @BeforeEach void setUp(){ String hostId = "hostUserId"; - List userList = new ArrayList<>(); project = new Project(); - project = project.createProjectDomain("Project Name", "Description", "img", hostId, userList); + project = project.createProjectDomain("Project Name", "Description", "img", hostId); project.setId("1"); } @Test diff --git a/src/test/java/domain/project/ProjectTest.java b/src/test/java/domain/project/ProjectTest.java index d412717..bbd56ba 100644 --- a/src/test/java/domain/project/ProjectTest.java +++ b/src/test/java/domain/project/ProjectTest.java @@ -46,7 +46,7 @@ void setup() { userList.add(user2); project = new Project(); - project = project.createProjectDomain("Project Name", "syncd", "img", hostId, userList); + project = project.createProjectDomain("Project Name", "syncd", "img", hostId); project.setId("1"); } From 5b5cc389e91feba7f24244c146326fa451d7e8e9 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 20 May 2024 20:19:53 +0900 Subject: [PATCH 16/21] merge --- .../com/syncd/AuthControllerProperties.java | 1 - .../syncd/adapter/in/web/AuthController.java | 9 +-- .../syncd/adapter/in/web/RoomController.java | 6 -- .../out/liveblock/LiveblockApiAdapter.java | 71 +++++++++++-------- .../port/in/GetRoomAuthTokenUsecase.java | 2 - .../port/in/SocialLoginUsecase.java | 2 +- .../port/out/liveblock/LiveblocksPort.java | 1 - .../application/service/LoginService.java | 2 + .../application/service/ProjectService.java | 7 +- .../adaptor/in/web/AuthControllerTest.java | 5 +- 10 files changed, 50 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/syncd/AuthControllerProperties.java b/src/main/java/com/syncd/AuthControllerProperties.java index c7bf7e7..5a0ac59 100644 --- a/src/main/java/com/syncd/AuthControllerProperties.java +++ b/src/main/java/com/syncd/AuthControllerProperties.java @@ -14,7 +14,6 @@ public class AuthControllerProperties { private String redirectUriForGoogleDev; - public String getRedirectUrl() { return redirectUrl; } diff --git a/src/main/java/com/syncd/adapter/in/web/AuthController.java b/src/main/java/com/syncd/adapter/in/web/AuthController.java index f1c8369..ea3f46a 100644 --- a/src/main/java/com/syncd/adapter/in/web/AuthController.java +++ b/src/main/java/com/syncd/adapter/in/web/AuthController.java @@ -2,21 +2,14 @@ import com.syncd.AuthControllerProperties; import com.syncd.application.port.in.SocialLoginUsecase; -import com.syncd.application.service.LoginService; import com.syncd.dto.TokenDto; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; -import java.util.Collection; -import java.util.Enumeration; - @RestController @RequiredArgsConstructor @RequestMapping("/login/oauth2") @@ -26,7 +19,7 @@ public class AuthController { @GetMapping("/code/{registrationId}") public RedirectView googleLogin(@RequestParam String code, - @PathVariable String registrationId) { + @PathVariable String registrationId, HttpServletResponse response) { String redirectUri = authControllerProperties.getRedirectUriForGoogle(); String url = authControllerProperties.getRedirectUrl(); TokenDto token = socialLoginUsecase.socialLogin(code, registrationId,redirectUri); diff --git a/src/main/java/com/syncd/adapter/in/web/RoomController.java b/src/main/java/com/syncd/adapter/in/web/RoomController.java index aae1b85..3bff2f8 100644 --- a/src/main/java/com/syncd/adapter/in/web/RoomController.java +++ b/src/main/java/com/syncd/adapter/in/web/RoomController.java @@ -29,10 +29,4 @@ public GetAllRoomsByUserIdResponseDto getAllInfoAboutRoomsByUserId(HttpServletRe String token = jwtService.resolveToken(request); return getAllRoomsByUserIdUsecase.getAllRoomsByUserId(jwtService.getUserIdFromToken(token)); } - - @PostMapping("/test-auth") - public GetRoomAuthTokenResponseDto getRoomAuthToken(@RequestBody @Valid GetRoomAuthTokenUsecase.TestDto getRoomAuthToken, HttpServletRequest request){ - String token = jwtService.resolveToken(request); - return getRoomAuthTokenUsecase.Test(jwtService.getUserIdFromToken(token),getRoomAuthToken.roomId()); - } } diff --git a/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java b/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java index 47f9cb9..b3d83e0 100644 --- a/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java +++ b/src/main/java/com/syncd/adapter/out/liveblock/LiveblockApiAdapter.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Value; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.function.Function; import java.util.stream.Collectors; @@ -34,7 +35,6 @@ public LiveblocksTokenDto GetRoomAuthToken(String userId, String name,String img String jsonBody = createJsonBody(userId,name,img,projectIds); HttpEntity request = new HttpEntity<>(jsonBody, headers); -// System.out.print(request); return restTemplate.postForObject(url, request, LiveblocksTokenDto.class); } @@ -51,47 +51,58 @@ private String createJsonBody(String userId,String name, String img, List request = new HttpEntity<>(jsonBody, headers); + // colors 배열 + private static String[][] colors = { + {"#FF0099", "#FF7A00"}, + {"#002A95", "#00A0D2"}, + {"#6116FF", "#E32DD1"}, + {"#0EC4D1", "#1BCC00"}, + {"#FF00C3", "#FF3333"}, + {"#00C04D", "#00FFF0"}, + {"#5A2BBE", "#C967EC"}, + {"#46BE2B", "#67EC86"}, + {"#F49300", "#FFE600"}, + {"#F42900", "#FF9000"}, + {"#00FF94", "#0094FF"}, + {"#00FF40", "#1500FF"}, + {"#00FFEA", "#BF00FF"}, + {"#FFD600", "#BF00FF"}, + {"#484559", "#282734"}, + {"#881B9A", "#1D051E"}, + {"#FF00F5", "#00FFD1"}, + {"#9A501B", "#1E0505"}, + {"#FF008A", "#FAFF00"}, + {"#22BC09", "#002B1B"}, + {"#FF0000", "#000000"}, + {"#00FFB2", "#000000"}, + {"#0066FF", "#000000"}, + {"#FA00FF", "#000000"}, + {"#00A3FF", "#000000"}, + {"#00FF94", "#000000"}, + {"#AD00FF", "#000000"}, + {"#F07777", "#4E0073"}, + {"#AC77F0", "#003C73"} + }; - return restTemplate.postForObject(url, request, LiveblocksTokenDto.class); + public static String[] getRandomColorPair() { + Random random = new Random(); + int index = random.nextInt(colors.length); + return colors[index]; } -} +} \ No newline at end of file diff --git a/src/main/java/com/syncd/application/port/in/GetRoomAuthTokenUsecase.java b/src/main/java/com/syncd/application/port/in/GetRoomAuthTokenUsecase.java index b45dc94..a6297de 100644 --- a/src/main/java/com/syncd/application/port/in/GetRoomAuthTokenUsecase.java +++ b/src/main/java/com/syncd/application/port/in/GetRoomAuthTokenUsecase.java @@ -12,8 +12,6 @@ GetRoomAuthTokenResponseDto getRoomAuthToken( String userId ); - GetRoomAuthTokenResponseDto Test(String uesrId, String roomId); - // ====================================== // DTO // ====================================== diff --git a/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java index 901ae42..42ef03e 100644 --- a/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java +++ b/src/main/java/com/syncd/application/port/in/SocialLoginUsecase.java @@ -3,5 +3,5 @@ import com.syncd.dto.TokenDto; public interface SocialLoginUsecase { - TokenDto socialLogin(String code, String registrationId, String redirectionUri); + TokenDto socialLogin(String code, String registrationId,String redirectionUri); } diff --git a/src/main/java/com/syncd/application/port/out/liveblock/LiveblocksPort.java b/src/main/java/com/syncd/application/port/out/liveblock/LiveblocksPort.java index 8eaa0b7..4d79ebe 100644 --- a/src/main/java/com/syncd/application/port/out/liveblock/LiveblocksPort.java +++ b/src/main/java/com/syncd/application/port/out/liveblock/LiveblocksPort.java @@ -6,6 +6,5 @@ public interface LiveblocksPort { LiveblocksTokenDto GetRoomAuthToken(String userId, String name,String img,List projectIds); - LiveblocksTokenDto Test(String userId, String roomId); } diff --git a/src/main/java/com/syncd/application/service/LoginService.java b/src/main/java/com/syncd/application/service/LoginService.java index 1e6136a..f5209ea 100644 --- a/src/main/java/com/syncd/application/service/LoginService.java +++ b/src/main/java/com/syncd/application/service/LoginService.java @@ -1,7 +1,9 @@ package com.syncd.application.service; import com.fasterxml.jackson.databind.JsonNode; +import com.syncd.GoogleOAuth2Properties; import com.syncd.application.port.in.GenerateTokenUsecase; +import com.syncd.application.port.in.GetOauthRedirectUrlUsecase; import com.syncd.application.port.in.SocialLoginUsecase; import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.application.port.out.persistence.user.WriteUserPort; diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index 8d57ccc..9d13026 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -21,6 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -95,11 +97,6 @@ public GetRoomAuthTokenResponseDto getRoomAuthToken(String userId) { return new GetRoomAuthTokenResponseDto(liveblocksPort.GetRoomAuthToken(userId, userInfo.getName(), userInfo.getProfileImg(), projectIds).token()); } - @Override - public GetRoomAuthTokenResponseDto Test(String userId, String roomId) { - return new GetRoomAuthTokenResponseDto(liveblocksPort.Test(userId, roomId).token()); - } - @Override public DeleteProjectResponseDto deleteProject(String userId, String projectId) { Project project = readProjectPort.findProjectByProjectId(projectId); diff --git a/src/test/java/adaptor/in/web/AuthControllerTest.java b/src/test/java/adaptor/in/web/AuthControllerTest.java index d49482d..4732e55 100644 --- a/src/test/java/adaptor/in/web/AuthControllerTest.java +++ b/src/test/java/adaptor/in/web/AuthControllerTest.java @@ -44,7 +44,7 @@ void testGoogleLogin_ValidRequest() { String refreshToken = "refreshToken"; when(authControllerProperties.getRedirectUrl()).thenReturn(redirectUrl); - when(socialLoginUsecase.socialLogin(anyString(), anyString())).thenReturn(new TokenDto(accessToken, refreshToken)); + when(socialLoginUsecase.socialLogin(anyString(), anyString(),anyString())).thenReturn(new TokenDto(accessToken, refreshToken)); HttpServletResponse response = mock(HttpServletResponse.class); RedirectView result = authController.googleLogin(code, registrationId, response); @@ -52,8 +52,9 @@ void testGoogleLogin_ValidRequest() { assertThat(result.getUrl()).isEqualTo(redirectUrl + accessToken); ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor redirectionUriCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor registrationIdCaptor = ArgumentCaptor.forClass(String.class); - verify(socialLoginUsecase, times(1)).socialLogin(codeCaptor.capture(), registrationIdCaptor.capture()); + verify(socialLoginUsecase, times(1)).socialLogin(codeCaptor.capture(), registrationIdCaptor.capture(),redirectionUriCaptor.capture()); assertThat(codeCaptor.getValue()).isEqualTo(code); assertThat(registrationIdCaptor.getValue()).isEqualTo(registrationId); From 8d0e9315f5922deba60b8dcaceb29316afc2a670 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Mon, 27 May 2024 21:55:06 +0900 Subject: [PATCH 17/21] feat: Write readme --- .../out/persistence/ProjectPersistenceAdapter.java | 1 + .../application/port/out/openai/ChatGPTPort.java | 1 - .../port/out/openai/GetChatGptPricePort.java | 13 +++++++++++++ .../syncd/application/service/ProjectService.java | 5 +++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/syncd/application/port/out/openai/GetChatGptPricePort.java diff --git a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java index d73062b..f78fbd6 100644 --- a/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java +++ b/src/main/java/com/syncd/adapter/out/persistence/ProjectPersistenceAdapter.java @@ -23,6 +23,7 @@ public class ProjectPersistenceAdapter implements WriteProjectPort, ReadProjectP @Override public List findAllProjectByUserId(String userId){ List projectEntityList = projectDao.findByUsersUserId(userId); + System.out.println(projectEntityList); // List projects = ProjectMapper.INSTANCE.fromProjectEntities(projectEntityList); List projects = projectEntityList.stream() .map(ProjectMapper.INSTANCE::mapProjectEntityToProject) diff --git a/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java index 2edb285..4c6ef9e 100644 --- a/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java +++ b/src/main/java/com/syncd/application/port/out/openai/ChatGPTPort.java @@ -18,7 +18,6 @@ @Service public interface ChatGPTPort { - MakeUserStoryResponseDto makeUserstory(List senarios); diff --git a/src/main/java/com/syncd/application/port/out/openai/GetChatGptPricePort.java b/src/main/java/com/syncd/application/port/out/openai/GetChatGptPricePort.java new file mode 100644 index 0000000..0a9bac8 --- /dev/null +++ b/src/main/java/com/syncd/application/port/out/openai/GetChatGptPricePort.java @@ -0,0 +1,13 @@ +package com.syncd.application.port.out.openai; + +import com.syncd.dto.MakeUserStoryResponseDto; +import org.springframework.stereotype.Service; + +import java.util.List; + + +@Service +public interface GetChatGptPricePort { + + MakeUserStoryResponseDto getChatGptPricePort(List senarios); +} \ No newline at end of file diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index 6d96d7a..0d8e93b 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -89,8 +89,11 @@ public JoinProjectUsecase.JoinProjectResponseDto joinProject(String userId, Stri @Override public GetAllRoomsByUserIdResponseDto getAllRoomsByUserId(String userId) { List projects = readProjectPort.findAllProjectByUserId(userId); + System.out.println(projects); // GetAllRoomsByUserIdResponseDto responseDto = projectMappers.mapProjectsToGetAllRoomsByUserIdResponseDto(userId, projects); GetAllRoomsByUserIdResponseDto responseDto = mapProjectsToResponse(userId, projects); + System.out.println("sout"); + System.out.println(responseDto); return responseDto; } @@ -206,6 +209,8 @@ private UserInProject createUserInProjectWithRoleMember(String userEmail, String } private GetAllRoomsByUserIdResponseDto mapProjectsToResponse(String userId, List projects) { + System.out.println("projectId"); + System.out.println(projects); List projectDtos = projects.stream() .map(project -> convertProjectToDto(userId, project)) .filter(dto -> dto != null) // Ensure that only relevant projects are included From 3093bec211996ea62dbfb17b136a3c16b2aea14c Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Thu, 30 May 2024 19:08:30 +0900 Subject: [PATCH 18/21] docs: Write readme --- README.md | 58 +-------------------------------- docs/HexagonalArchitecture.png | Bin 35294 -> 62535 bytes 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/README.md b/README.md index b61dc70..1258a42 100644 --- a/README.md +++ b/README.md @@ -4,60 +4,4 @@ ![](./docs/HexagonalArchitecture.png) layer 구조는 layer 간의 의존성이 너무 얽혀있어 보기 아키텍처를 파악하기 어려워 hexagonal 구조를 채택하였습니다. -## Directory -헥사고날 구현을 위한 파일 구조 (...은 같은 유형의 파일 or 디렉토리 반복을 의미합니다.) -``` -src -├─main -│ ├─java -│ │ └─com.syncd -│ │ ├─adapter -│ │ │ ├─in -│ │ │ │ ├─web -│ │ │ │ │ ├─exception -│ │ │ │ │ │ ├─exceptions -│ │ │ │ │ │ │ ├─Exception.java -│ │ │ │ │ │ │ └─ ... -│ │ │ │ │ │ ├─ExceptionHandler.java -│ │ │ │ │ │ └─ ... -│ │ │ │ │ ├─WebConfig.java -│ │ │ │ │ ├─Controller.java -│ │ │ │ │ └─ ... -│ │ │ └─out -│ │ │ ├─liveblock -│ │ │ │ └─LiveblockApiAdapter.java -│ │ │ └─persistence -│ │ │ ├─repository -│ │ │ │ ├─collection -│ │ │ │ │ ├─CollectionDao.java -│ │ │ │ │ └─CollectionEntity.java -│ │ │ │ └─ ... -│ │ │ ├─MongoConfig.java -│ │ │ ├─PersistenceAdapter.java -│ │ │ └─ ... -│ │ ├─applicaion -│ │ │ ├─domain -│ │ │ │ ├─domainName -│ │ │ │ │ ├─DomainName.java -│ │ │ │ │ └─DomainNameMapper.java -│ │ │ │ └─ ... -│ │ │ ├─port -│ │ │ │ ├─in -│ │ │ │ │ ├─Usecase.java -│ │ │ │ │ └─ ... -│ │ │ │ └─out -│ │ │ │ ├─portName -│ │ │ │ ├─dto -│ │ │ │ │ ├─Dto.java -│ │ │ │ │ └─ ... -│ │ │ │ ├─Port.java -│ │ │ │ └─ ... -│ │ │ └─service -│ │ ├─enums -│ │ │ ├─Enum.java -│ │ │ └─ ... -│ │ └─SyncdApplication -│ └─resources -└─test - └─(구현 중) -``` +## 스프링 자바 그래들 \ No newline at end of file diff --git a/docs/HexagonalArchitecture.png b/docs/HexagonalArchitecture.png index d7d3e0d12fe6a0fb51f1231e001225a5c1e82fbb..c98376cb0548c41bc39f9d156b5bf13f56bf6fed 100644 GIT binary patch literal 62535 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 P;18p3s`p&SG4wwGAsRt_ literal 35294 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 From ab26b2fd9ec933d69f0985cb8d9ed09e45d43941 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 2 Jun 2024 15:00:38 +0900 Subject: [PATCH 19/21] test: Make project service test --- .../application/service/ProjectService.java | 2 + .../com/syncd/domain/project/Project.java | 13 +- src/test/java/Dummy/Consistent.java | 21 ++ .../out/gmail/StubSendMailPort.java | 19 ++ .../out/liveblock/StubLiveblocksPort.java | 14 + .../out/openai/StubChatGPTPort.java | 13 + .../project/StubReadProjectPort.java | 27 ++ .../project/StubWriteProjectPort.java | 32 +++ .../persistence/user/StubReadUserPort.java | 53 ++++ .../persistence/user/StubWriteUserPort.java | 18 ++ .../Stub/application/out/s3/StubS3Port.java | 20 ++ src/test/java/Dummy/domain/MockProject.java | 30 ++ .../adaptor/in/web/RoomControllerTest.java | 26 -- .../port/in/CreateProjectUsecaseTest.java | 79 ----- .../port/in/DeleteProjectUsecaseTest.java | 44 --- .../in/GetAllRoomsByUserIdUsecaseTest.java | 55 ---- .../port/in/GetRoomAuthTokenUsecaseTest.java | 31 -- .../in/InviteUserInProjectUsecaseTest.java | 49 ---- .../port/in/RegisterUserUsecaseTest.java | 34 --- .../port/in/UpdateProjectUsecaseTest.java | 51 ---- .../in/WithdrawUserInProjectUsecaseTest.java | 39 --- .../autentication/AutenticationPortTest.java | 37 --- .../port/out/liveblock/LiveblockPortTest.java | 37 --- .../project/ReadProjectPortTest.java | 66 ----- .../project/WriteProjectPortTest.java | 57 ---- .../persistence/user/ReadUserPortTest.java | 71 ----- .../persistence/user/WriteUserPortTest.java | 43 --- .../application/service/LoginServiceTest.java | 65 ----- .../service/ProjectServiceTest.java | 270 +++++++++++++++++- 29 files changed, 526 insertions(+), 790 deletions(-) create mode 100644 src/test/java/Dummy/Consistent.java create mode 100644 src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/liveblock/StubLiveblocksPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/openai/StubChatGPTPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/persistence/project/StubReadProjectPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/persistence/project/StubWriteProjectPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/persistence/user/StubReadUserPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/persistence/user/StubWriteUserPort.java create mode 100644 src/test/java/Dummy/Stub/application/out/s3/StubS3Port.java create mode 100644 src/test/java/Dummy/domain/MockProject.java delete mode 100644 src/test/java/application/port/in/CreateProjectUsecaseTest.java delete mode 100644 src/test/java/application/port/in/DeleteProjectUsecaseTest.java delete mode 100644 src/test/java/application/port/in/GetAllRoomsByUserIdUsecaseTest.java delete mode 100644 src/test/java/application/port/in/GetRoomAuthTokenUsecaseTest.java delete mode 100644 src/test/java/application/port/in/InviteUserInProjectUsecaseTest.java delete mode 100644 src/test/java/application/port/in/RegisterUserUsecaseTest.java delete mode 100644 src/test/java/application/port/in/UpdateProjectUsecaseTest.java delete mode 100644 src/test/java/application/port/in/WithdrawUserInProjectUsecaseTest.java delete mode 100644 src/test/java/application/port/out/autentication/AutenticationPortTest.java delete mode 100644 src/test/java/application/port/out/liveblock/LiveblockPortTest.java delete mode 100644 src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java delete mode 100644 src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java delete mode 100644 src/test/java/application/port/out/persistence/user/ReadUserPortTest.java delete mode 100644 src/test/java/application/port/out/persistence/user/WriteUserPortTest.java delete mode 100644 src/test/java/application/service/LoginServiceTest.java diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index 0d8e93b..a61d63b 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -41,6 +41,7 @@ public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserId private final SendMailPort sendMailPort; private final ChatGPTPort chatGPTPort; private final S3Port s3Port; + private final ProjectMapper projectMappers; @Override @@ -184,6 +185,7 @@ public MakeUserStoryResponseDto makeUserstory(String userId, String projectId, L .anyMatch(user -> user.getUserId().equals(userId)); if(!containsUserIdA){ + System.out.println(project); throw new CustomException(ErrorInfo.NOT_INCLUDE_PROJECT, "project id" + projectId); } project.subLeftChanceForUserstory(); diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index a866e37..35dada3 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -39,11 +39,14 @@ public void withdrawUsers(List userIds) { } public String getHost() { - return this.users.stream() - .filter(user -> user.getRole() == Role.HOST) - .map(UserInProject::getUserId) - .findFirst() - .orElse(null); // Returns null if no host is found + if(this.users != null){ + return this.users.stream() + .filter(user -> user.getRole() == Role.HOST) + .map(UserInProject::getUserId) + .findFirst() + .orElse(null); // Returns null if no host is found + } + return null; } public void updateProjectInfo(String projectName, String description, String img){ diff --git a/src/test/java/Dummy/Consistent.java b/src/test/java/Dummy/Consistent.java new file mode 100644 index 0000000..94e1a07 --- /dev/null +++ b/src/test/java/Dummy/Consistent.java @@ -0,0 +1,21 @@ +package Dummy; + +public enum Consistent { + ProjectId("DummyProjectId"), + UserId("DummyUserId"), + UserName("DummyUserName"), + ProjectName("DummyProjectName"), + LiveblocksToken("DummyLiveblocksToken"), + S3Link("DummyS3Link"); + + + private final String value; + + Consistent(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java b/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java new file mode 100644 index 0000000..437da7c --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java @@ -0,0 +1,19 @@ +package Dummy.Stub.application.out.gmail; + +import Dummy.Consistent; +import com.syncd.application.port.out.gmail.SendMailPort; +import com.syncd.domain.user.User; + +import java.util.List; + +public class StubSendMailPort implements SendMailPort { + @Override + public String sendInviteMail(String email, String hostName, String userName, String projectName, String ProjectId) { + return Consistent.ProjectName.getValue(); + } + + @Override + public String sendIviteMailBatch(String hostName, String projectName, List users, String ProjectId) { + return Consistent.ProjectName.getValue(); + } +} diff --git a/src/test/java/Dummy/Stub/application/out/liveblock/StubLiveblocksPort.java b/src/test/java/Dummy/Stub/application/out/liveblock/StubLiveblocksPort.java new file mode 100644 index 0000000..097d455 --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/liveblock/StubLiveblocksPort.java @@ -0,0 +1,14 @@ +package Dummy.Stub.application.out.liveblock; + +import Dummy.Consistent; +import com.syncd.application.port.out.liveblock.LiveblocksPort; +import com.syncd.dto.LiveblocksTokenDto; + +import java.util.List; + +public class StubLiveblocksPort implements LiveblocksPort { + @Override + public LiveblocksTokenDto GetRoomAuthToken(String userId, String name, String img, List projectIds) { + return new LiveblocksTokenDto(Consistent.LiveblocksToken.getValue()); + } +} diff --git a/src/test/java/Dummy/Stub/application/out/openai/StubChatGPTPort.java b/src/test/java/Dummy/Stub/application/out/openai/StubChatGPTPort.java new file mode 100644 index 0000000..1b7954c --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/openai/StubChatGPTPort.java @@ -0,0 +1,13 @@ +package Dummy.Stub.application.out.openai; + +import com.syncd.application.port.out.openai.ChatGPTPort; +import com.syncd.dto.MakeUserStoryResponseDto; + +import java.util.List; + +public class StubChatGPTPort implements ChatGPTPort { + @Override + public MakeUserStoryResponseDto makeUserstory(List senarios) { + return new MakeUserStoryResponseDto(); + } +} diff --git a/src/test/java/Dummy/Stub/application/out/persistence/project/StubReadProjectPort.java b/src/test/java/Dummy/Stub/application/out/persistence/project/StubReadProjectPort.java new file mode 100644 index 0000000..410a65a --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/persistence/project/StubReadProjectPort.java @@ -0,0 +1,27 @@ +package Dummy.Stub.application.out.persistence.project; + +import Dummy.Consistent; +import Dummy.domain.MockProject; +import com.syncd.application.port.out.persistence.project.ReadProjectPort; +import com.syncd.domain.project.Project; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class StubReadProjectPort implements ReadProjectPort { + + @Override + public Project findProjectByProjectId(String projectId) { + Project project = new MockProject(); + return project; + } + + @Override + public List findAllProjectByUserId(String userId) { + Project project = new MockProject(); + List projects = new ArrayList<>(); + projects.add(project); + return projects; + } +} diff --git a/src/test/java/Dummy/Stub/application/out/persistence/project/StubWriteProjectPort.java b/src/test/java/Dummy/Stub/application/out/persistence/project/StubWriteProjectPort.java new file mode 100644 index 0000000..765cc35 --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/persistence/project/StubWriteProjectPort.java @@ -0,0 +1,32 @@ +package Dummy.Stub.application.out.persistence.project; + +import Dummy.Consistent; +import com.syncd.application.port.out.persistence.project.WriteProjectPort; +import com.syncd.domain.project.Project; + +public class StubWriteProjectPort implements WriteProjectPort { + + + @Override + public String CreateProject(Project project) { + return Consistent.ProjectId.getValue(); + } + + @Override + public void RemoveProject(String projectId) {} + + @Override + public String UpdateProject(Project project) { + return project.getId(); + } + + @Override + public String AddProgress(String projectId, int projectStage) { + return projectId; + } + + @Override + public String updateLastModifiedDate(String projectId) { + return projectId; + } +} diff --git a/src/test/java/Dummy/Stub/application/out/persistence/user/StubReadUserPort.java b/src/test/java/Dummy/Stub/application/out/persistence/user/StubReadUserPort.java new file mode 100644 index 0000000..2f84122 --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/persistence/user/StubReadUserPort.java @@ -0,0 +1,53 @@ +package Dummy.Stub.application.out.persistence.user; + +import Dummy.Consistent; +import com.syncd.application.port.out.persistence.user.ReadUserPort; +import com.syncd.domain.user.User; + +import java.util.ArrayList; +import java.util.List; + +public class StubReadUserPort implements ReadUserPort { + + @Override + public User findByEmail(String email) { + User user = new User(); + user.setId(Consistent.UserId.getValue()); + user.setName(Consistent.UserName.getValue()); + return user; + } + + @Override + public User findByUsername(String username) { + User user = new User(); + user.setId(Consistent.UserId.getValue()); + user.setName(Consistent.UserName.getValue()); + return user; + } + + @Override + public User findByUserId(String username) { + User user = new User(); + user.setId(Consistent.UserId.getValue()); + user.setName(Consistent.UserName.getValue()); + return user; + } + + @Override + public boolean isExistUser(String email) { + if(email.equals("true@gmail.com")){ + return true; + } + return false; + } + + @Override + public List usersFromEmails(List emails) { + List users = new ArrayList<>(); + User user = new User(); + user.setId(Consistent.UserId.getValue()); + user.setName(Consistent.UserName.getValue()); + users.add(user); + return users; + } +} diff --git a/src/test/java/Dummy/Stub/application/out/persistence/user/StubWriteUserPort.java b/src/test/java/Dummy/Stub/application/out/persistence/user/StubWriteUserPort.java new file mode 100644 index 0000000..6c34c3f --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/persistence/user/StubWriteUserPort.java @@ -0,0 +1,18 @@ +package Dummy.Stub.application.out.persistence.user; + +import Dummy.Consistent; +import com.syncd.application.port.out.persistence.user.WriteUserPort; +import com.syncd.domain.user.User; +import com.syncd.dto.UserId; + +public class StubWriteUserPort implements WriteUserPort { + @Override + public UserId createUser(String userName, String email, String img) { + return new UserId(Consistent.UserId.getValue()); + } + + @Override + public UserId updateUser(User user) { + return new UserId(Consistent.UserId.getValue()); + } +} diff --git a/src/test/java/Dummy/Stub/application/out/s3/StubS3Port.java b/src/test/java/Dummy/Stub/application/out/s3/StubS3Port.java new file mode 100644 index 0000000..cd01eca --- /dev/null +++ b/src/test/java/Dummy/Stub/application/out/s3/StubS3Port.java @@ -0,0 +1,20 @@ +package Dummy.Stub.application.out.s3; + +import Dummy.Consistent; +import com.syncd.application.port.out.s3.S3Port; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Optional; + +public class StubS3Port implements S3Port { + + @Override + public Optional uploadMultipartFileToS3(MultipartFile multipartFile, String name, String id) { + return Optional.of(Consistent.S3Link.getValue()); + } + + @Override + public Optional deleteFileFromS3(String filename) { + return Optional.of(true); + } +} diff --git a/src/test/java/Dummy/domain/MockProject.java b/src/test/java/Dummy/domain/MockProject.java new file mode 100644 index 0000000..1d1a26d --- /dev/null +++ b/src/test/java/Dummy/domain/MockProject.java @@ -0,0 +1,30 @@ +package Dummy.domain; + +import Dummy.Consistent; +import com.syncd.domain.project.Project; +import com.syncd.domain.project.UserInProject; +import com.syncd.enums.Role; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class MockProject extends Project { + public MockProject() { + this.setId(Consistent.ProjectId.getValue()); + UserInProject user = new UserInProject(Consistent.UserId.getValue(), Role.HOST); + List users = new ArrayList<>(); + users.add(user); + this.addUsers(users); + // Set default values + this.setImg(""); // 이미지 기본값 설정 + this.setProgress(0); // 진행률 기본값 설정 + this.setLastModifiedDate(LocalDateTime.now().toString()); // 마지막 수정 날짜 기본값 설정 + this.setLeftChanceForUserstory(3); // 남은 유저 스토리 기회 기본값 설정 + } + + @Override + public String getHost() { + return Consistent.UserId.getValue(); + } +} diff --git a/src/test/java/adaptor/in/web/RoomControllerTest.java b/src/test/java/adaptor/in/web/RoomControllerTest.java index 827bfac..7c12df0 100644 --- a/src/test/java/adaptor/in/web/RoomControllerTest.java +++ b/src/test/java/adaptor/in/web/RoomControllerTest.java @@ -62,24 +62,6 @@ void testGetRoomAuthToken_ValidRequest() { verifyGetRoomAuthToken("userId"); } - @Test - @DisplayName("Get Room Auth Token - Test - Valid Request") - void testGetRoomAuthTokenTest_ValidRequest() { - HttpServletRequest request = mock(HttpServletRequest.class); - setupMockJwtService(request, "token", "userId"); - - TestDto testDto = new TestDto("validRoomId"); - GetRoomAuthTokenResponseDto responseDto = new GetRoomAuthTokenResponseDto("testAuthToken"); - when(getRoomAuthTokenUsecase.Test(anyString(), anyString())).thenReturn(responseDto); - - GetRoomAuthTokenResponseDto response = roomController.getRoomAuthToken(testDto, request); - - assertThat(response).isNotNull(); - assertThat(response.token()).isEqualTo("testAuthToken"); - - verifyJwtServiceInteraction(request, "token"); - verifyTestRoomAuthToken("userId", "validRoomId"); - } // ====================================== // GetAllRoomsByUserIdUsecase @@ -125,12 +107,4 @@ private void verifyGetAllRoomsByUserId(String userId) { assertThat(userIdCaptor.getValue()).isEqualTo(userId); } - private void verifyTestRoomAuthToken(String userId, String roomId) { - ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor roomIdCaptor = ArgumentCaptor.forClass(String.class); - verify(getRoomAuthTokenUsecase).Test(userIdCaptor.capture(), roomIdCaptor.capture()); - assertThat(userIdCaptor.getValue()).isEqualTo(userId); - assertThat(roomIdCaptor.getValue()).isEqualTo(roomId); - } - } diff --git a/src/test/java/application/port/in/CreateProjectUsecaseTest.java b/src/test/java/application/port/in/CreateProjectUsecaseTest.java deleted file mode 100644 index a9b96e6..0000000 --- a/src/test/java/application/port/in/CreateProjectUsecaseTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; -import com.syncd.application.port.in.CreateProjectUsecase; -import com.syncd.application.port.in.CreateProjectUsecase.*; -import com.syncd.exceptions.CustomException; -import com.syncd.exceptions.ErrorInfo; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; - -import java.util.Arrays; -import java.util.List; - -@ExtendWith(MockitoExtension.class) -public class CreateProjectUsecaseTest { - @Mock - private CreateProjectUsecase createProjectUsecase; - - String userId = "user123"; - String userName = "user"; - String name = "New Project"; - String description = "Description of new project"; - MockMultipartFile img = new MockMultipartFile("img", "path.png", "image/png", "Image data".getBytes()); - - @Test - void testCreateProject() { - List users = Arrays.asList("user1", "user2"); - CreateProjectResponseDto response = new CreateProjectResponseDto("proj123"); - when(createProjectUsecase.createProject(userId,userName, name, description, img, users)).thenReturn(response); - - // Execution - CreateProjectResponseDto result = createProjectUsecase.createProject(userId,userName, name, description, img, users); - - // Verification - assertEquals("proj123", result.projectId()); - verify(createProjectUsecase).createProject(userId,userName, name, description, img, users); - } - - @Test - void testCreateProjectWhenServiceThrowsException() { - List users = Arrays.asList("user1", "user2"); - when(createProjectUsecase.createProject(userId,userName, name, description, img, users)) - .thenThrow(new IllegalStateException("Database error")); - - assertThrows(IllegalStateException.class, () -> { - createProjectUsecase.createProject(userId,userName, name, description, img, users); - }); - } - - @Test - void testCreateProjectWhenUserNotFoundException() { - List users = Arrays.asList("invalidUser@example.com"); - - when(createProjectUsecase.createProject(userId, userName, name, description, img, users)) - .thenThrow(new CustomException(ErrorInfo.USER_NOT_FOUND, "user id : " + userId)); - - // Execution and Verification - assertThrows(CustomException.class, () -> { - createProjectUsecase.createProject(userId, userName, name, description, img, users); - }); - } - - @Test - void testCreateProjectWhenProjectAlreadyExistsException() { - List users = Arrays.asList("user1@example.com"); - - when(createProjectUsecase.createProject(userId, userName, name, description, img, users)) - .thenThrow(new CustomException(ErrorInfo.PROJECT_ALREADY_EXISTS, "")); - - // Execution and Verification - assertThrows(CustomException.class, () -> { - createProjectUsecase.createProject(userId, userName, name, description, img, users); - }); - } -} diff --git a/src/test/java/application/port/in/DeleteProjectUsecaseTest.java b/src/test/java/application/port/in/DeleteProjectUsecaseTest.java deleted file mode 100644 index d304d77..0000000 --- a/src/test/java/application/port/in/DeleteProjectUsecaseTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.in.DeleteProjectUsecase; -import com.syncd.application.port.in.DeleteProjectUsecase.*; -import com.syncd.exceptions.CustomException; -import com.syncd.exceptions.ErrorInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class DeleteProjectUsecaseTest { - @Mock - private DeleteProjectUsecase deleteProjectUsecase; - String userId = "user1"; - @Test - @DisplayName("") - void testDeleteProject(){ - String projectId = "project123"; - DeleteProjectResponseDto expectedResponse = new DeleteProjectResponseDto(projectId); - - when(deleteProjectUsecase.deleteProject(userId, projectId)).thenReturn(expectedResponse); - DeleteProjectResponseDto actualResponse = deleteProjectUsecase.deleteProject(userId, projectId); - assertEquals(expectedResponse.projectId(), actualResponse.projectId(), "ProjectID와 삭제된 ProjectID가 불일치합니다."); - } - - @Test - @DisplayName("Throw exception when project does not exist") - void testDeleteNonExistingProject() { - String projectId = "nonExistingProject"; - - when(deleteProjectUsecase.deleteProject(userId, projectId)) - .thenThrow(new CustomException(ErrorInfo.PROJECT_NOT_FOUND, "project id : " + projectId)); - - assertThrows(CustomException.class, () -> deleteProjectUsecase.deleteProject(userId, projectId), - "Deleting a non-existing project should throw ProjectNotFoundException."); - } - -} diff --git a/src/test/java/application/port/in/GetAllRoomsByUserIdUsecaseTest.java b/src/test/java/application/port/in/GetAllRoomsByUserIdUsecaseTest.java deleted file mode 100644 index d536ac7..0000000 --- a/src/test/java/application/port/in/GetAllRoomsByUserIdUsecaseTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.in.GetAllRoomsByUserIdUsecase; -import com.syncd.application.port.in.GetAllRoomsByUserIdUsecase.*; -import com.syncd.enums.Role; -import com.syncd.exceptions.CustomException; -import com.syncd.exceptions.ErrorInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -@ExtendWith(MockitoExtension.class) -public class GetAllRoomsByUserIdUsecaseTest { - @Mock - private GetAllRoomsByUserIdUsecase getAllRoomsByUserIdUsecase; - - @Test - @DisplayName("Test fetching all rooms by user ID") - void testGetAllRoomByUserId() { - String userId = "user1"; - List projects = List.of( - new ProjectForGetAllInfoAboutRoomsByUserIdResponseDto("Project A", "1", "Description A", Role.HOST, List.of("userA@example.com"), 90, "20200101"), - new ProjectForGetAllInfoAboutRoomsByUserIdResponseDto("Project B", "2", "Description B", Role.MEMBER, List.of("userB@example.com"), 75, "20200201") - ); - - GetAllRoomsByUserIdResponseDto expectedResponse = new GetAllRoomsByUserIdResponseDto(userId, projects); - - when(getAllRoomsByUserIdUsecase.getAllRoomsByUserId(userId)).thenReturn(expectedResponse); - - GetAllRoomsByUserIdResponseDto actualResponse = getAllRoomsByUserIdUsecase.getAllRoomsByUserId(userId); - - assertEquals(expectedResponse.userId(), actualResponse.userId(), "User ID does not match."); - assertIterableEquals(expectedResponse.projects(), actualResponse.projects(), "Projects do not match."); - } - - - @Test - @DisplayName("Test fetching all rooms by user ID when user not found") - void testGetAllRoomByUserIdWhenUserNotFound() { - String invalidUserId = "userNotFound"; - - when(getAllRoomsByUserIdUsecase.getAllRoomsByUserId(invalidUserId)) - .thenThrow(new CustomException(ErrorInfo.USER_NOT_FOUND, "User id : " + invalidUserId)); - - assertThrows(CustomException.class, () -> getAllRoomsByUserIdUsecase.getAllRoomsByUserId(invalidUserId), - "Fetching rooms for a non-existent user should throw UserNotFoundException."); - } -} \ No newline at end of file diff --git a/src/test/java/application/port/in/GetRoomAuthTokenUsecaseTest.java b/src/test/java/application/port/in/GetRoomAuthTokenUsecaseTest.java deleted file mode 100644 index e398868..0000000 --- a/src/test/java/application/port/in/GetRoomAuthTokenUsecaseTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.in.GetRoomAuthTokenUsecase; -import com.syncd.application.port.in.GetRoomAuthTokenUsecase.*; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class GetRoomAuthTokenUsecaseTest { - @Mock - private GetRoomAuthTokenUsecase getRoomAuthTokenUsecase; - - @Test - @DisplayName("UserID를 기반으로한 토큰 발급 테스트") - void testGetRoomAuthToken(){ - String userId = "123"; - GetRoomAuthTokenResponseDto expectedResponse = new GetRoomAuthTokenResponseDto("validToken123"); - when(getRoomAuthTokenUsecase.getRoomAuthToken(userId)).thenReturn(expectedResponse); - - GetRoomAuthTokenResponseDto actualResponse = getRoomAuthTokenUsecase.getRoomAuthToken(userId); - - assertEquals(expectedResponse.token(), actualResponse.token()); - verify(getRoomAuthTokenUsecase).getRoomAuthToken(userId); - } -} diff --git a/src/test/java/application/port/in/InviteUserInProjectUsecaseTest.java b/src/test/java/application/port/in/InviteUserInProjectUsecaseTest.java deleted file mode 100644 index 726a748..0000000 --- a/src/test/java/application/port/in/InviteUserInProjectUsecaseTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - - -import com.syncd.application.port.in.InviteUserInProjectUsecase; -import com.syncd.application.port.in.InviteUserInProjectUsecase.*; - -import com.syncd.exceptions.CustomException; -import com.syncd.exceptions.ErrorInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -@ExtendWith(MockitoExtension.class) -public class InviteUserInProjectUsecaseTest { - @Mock - private InviteUserInProjectUsecase inviteUserInProjectUsecase; - String userId = "user123"; - List users = List.of("user234", "user345"); - - @Test - void testInviteUserInProject(){ - String projectId = "project456"; - InviteUserInProjectResponseDto expectedResponse = new InviteUserInProjectResponseDto(projectId); - when(inviteUserInProjectUsecase.inviteUserInProject(userId, projectId, users)).thenReturn(expectedResponse); - - InviteUserInProjectResponseDto actualResponse = inviteUserInProjectUsecase.inviteUserInProject(userId, projectId, users); - assertEquals(expectedResponse.projectId(), actualResponse.projectId()); - verify(inviteUserInProjectUsecase).inviteUserInProject(userId, projectId, users); - } - - @Test - @DisplayName("Project 초대 시 존재하지 않는 Project에 대한 예외 처리") - void testInviteUserInNonExistingProject() { - String projectId = "nonExistingProject"; - - when(inviteUserInProjectUsecase.inviteUserInProject(userId, projectId, users)) - .thenThrow(new CustomException(ErrorInfo.PROJECT_NOT_FOUND, "project id : " + projectId)); - - assertThrows(CustomException.class, () -> inviteUserInProjectUsecase.inviteUserInProject(userId, projectId, users), - "Should throw ProjectNotFoundException if the project does not exist"); - } -} diff --git a/src/test/java/application/port/in/RegisterUserUsecaseTest.java b/src/test/java/application/port/in/RegisterUserUsecaseTest.java deleted file mode 100644 index 18cb868..0000000 --- a/src/test/java/application/port/in/RegisterUserUsecaseTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.in.RegitsterUserUsecase; -import com.syncd.application.port.in.RegitsterUserUsecase.*; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class RegisterUserUsecaseTest { - @Mock - private RegitsterUserUsecase registerUserUsecase; - - @Test - @DisplayName("로그인 후 access, refresh token 발급 테스트") - void testUserRegistration(){ - RegisterUserRequestDto registerDto = new RegisterUserRequestDto("민수", "민수@example.com", "password123"); - RegisterUserResponseDto expectedResponse = new RegisterUserResponseDto("access_token123", "refresh_token456"); - - when(registerUserUsecase.registerUser(registerDto)).thenReturn(expectedResponse); - - RegisterUserResponseDto actualResponse = registerUserUsecase.registerUser(registerDto); - - assertEquals(expectedResponse.accessToken(), actualResponse.accessToken(), "Access Token이 매칭이 안됩니다"); - assertEquals(expectedResponse.refreshToken(), actualResponse.refreshToken(), "Refresh Token이 매칭이 안됩니다."); - verify(registerUserUsecase).registerUser(registerDto); - } - -} diff --git a/src/test/java/application/port/in/UpdateProjectUsecaseTest.java b/src/test/java/application/port/in/UpdateProjectUsecaseTest.java deleted file mode 100644 index 1a2aa21..0000000 --- a/src/test/java/application/port/in/UpdateProjectUsecaseTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.in.UpdateProjectUsecase; -import com.syncd.application.port.in.UpdateProjectUsecase.*; -import com.syncd.exceptions.CustomException; -import com.syncd.exceptions.ErrorInfo; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class UpdateProjectUsecaseTest { - @Mock - private UpdateProjectUsecase updateProjectUsecase; - String userId = "user123"; - String projectName = "공감대"; - String description = "이해하지마 공감해"; - String image = "updated_image.jpg"; - - @Test - @DisplayName("ProjectID를 기반으로 Project 정보 업데이트 테스트") - void testUpdateProject(){ - - String projectId = "project456"; - UpdateProjectResponseDto expectedResponse = new UpdateProjectResponseDto(projectId); - when(updateProjectUsecase.updateProject(userId, projectId, projectName, description, image)) - .thenReturn(expectedResponse); - - UpdateProjectResponseDto actualResponse = updateProjectUsecase.updateProject(userId, projectId, projectName, description, image); - - assertEquals(expectedResponse.projectId(), actualResponse.projectId()); - verify(updateProjectUsecase).updateProject(userId, projectId, projectName, description, image); - } - - @Test - @DisplayName("Project 업데이트 시 존재하지 않는 프로젝트에 대한 예외 처리") - void testUpdateNonExistingProject() { - String projectId = "nonExistingProject"; - - when(updateProjectUsecase.updateProject(userId, projectId, projectName, description, image)) - .thenThrow(new CustomException(ErrorInfo.PROJECT_NOT_FOUND, "project id : " + projectId)); - - assertThrows(CustomException.class, () -> updateProjectUsecase.updateProject(userId, projectId, projectName, description, image), - "Updating a non-existing project should throw ProjectNotFoundException."); - } -} diff --git a/src/test/java/application/port/in/WithdrawUserInProjectUsecaseTest.java b/src/test/java/application/port/in/WithdrawUserInProjectUsecaseTest.java deleted file mode 100644 index 0ced6da..0000000 --- a/src/test/java/application/port/in/WithdrawUserInProjectUsecaseTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package application.port.in; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - - -import com.syncd.application.port.in.WithdrawUserInProjectUsecase; -import com.syncd.application.port.in.WithdrawUserInProjectUsecase.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -@ExtendWith(MockitoExtension.class) -public class WithdrawUserInProjectUsecaseTest { - @Mock - private WithdrawUserInProjectUsecase withdrawUserInProjectUsecase; - - @Test - @DisplayName("ProjectID를 기반으로 Project 삭제 테스트") - void testWithdrawUserInProject(){ - String userId = "user123"; - String projectId = "project456"; - List users = List.of("user234", "user345"); - WithdrawUserInProjectResponseDto expectedResponse = new WithdrawUserInProjectResponseDto(projectId); - - when(withdrawUserInProjectUsecase.withdrawUserInProject(userId, projectId, users)) - .thenReturn(expectedResponse); - - WithdrawUserInProjectResponseDto actualResponse = withdrawUserInProjectUsecase.withdrawUserInProject(userId, projectId, users); - - assertEquals(expectedResponse.projectId(), actualResponse.projectId()); - verify(withdrawUserInProjectUsecase).withdrawUserInProject(userId, projectId, users); - } -} diff --git a/src/test/java/application/port/out/autentication/AutenticationPortTest.java b/src/test/java/application/port/out/autentication/AutenticationPortTest.java deleted file mode 100644 index 5663645..0000000 --- a/src/test/java/application/port/out/autentication/AutenticationPortTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package application.port.out.autentication; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - - -import com.syncd.application.port.out.autentication.AuthenticationPort; -import com.syncd.dto.TokenDto; -import com.syncd.dto.UserForTokenDto; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class AutenticationPortTest { - @Mock - private AuthenticationPort authenticationPort; - - @Test - @DisplayName("") - void testGetJwtTokens(){ - UserForTokenDto userForTokenDto = new UserForTokenDto("user123"); - - TokenDto expectedToken = new TokenDto("accessToken123", "refreshToken123"); - - when(authenticationPort.GetJwtTokens(userForTokenDto)).thenReturn(expectedToken); - - TokenDto actualToken = authenticationPort.GetJwtTokens(userForTokenDto); - - assertNotNull(actualToken, "Token이 Null입니다."); - assertEquals(expectedToken.accessToken(), actualToken.accessToken(), "Access Token이 매칭되지 않습니다."); - assertEquals(expectedToken.refreshToken(), actualToken.refreshToken(), "Refresh Token이 매칭되지 않습니다."); - verify(authenticationPort).GetJwtTokens(userForTokenDto); - } -} diff --git a/src/test/java/application/port/out/liveblock/LiveblockPortTest.java b/src/test/java/application/port/out/liveblock/LiveblockPortTest.java deleted file mode 100644 index 6497d23..0000000 --- a/src/test/java/application/port/out/liveblock/LiveblockPortTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package application.port.out.liveblock; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -import com.syncd.application.port.out.liveblock.LiveblocksPort; -import com.syncd.dto.LiveblocksTokenDto; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -@ExtendWith(MockitoExtension.class) -public class LiveblockPortTest { - @Mock - private LiveblocksPort liveblocksPort; - - @Test - @DisplayName("") - void testGetRoomAuthToken(){ - String userId = "user123"; - String name = "태욱"; - String img = "profile.jpg"; - List projectIds = List.of("project1", "project2"); - LiveblocksTokenDto expectedToken = new LiveblocksTokenDto("access_token123"); - - when(liveblocksPort.GetRoomAuthToken(userId, name, img, projectIds)).thenReturn(expectedToken); - - LiveblocksTokenDto actualToken = liveblocksPort.GetRoomAuthToken(userId, name, img, projectIds); - - assertNotNull(actualToken); - assertEquals(expectedToken.token(), actualToken.token()); - } -} diff --git a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java b/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java deleted file mode 100644 index 34a8036..0000000 --- a/src/test/java/application/port/out/persistence/project/ReadProjectPortTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package application.port.out.persistence.project; - -import com.syncd.application.port.out.persistence.project.ReadProjectPort; -import com.syncd.domain.project.Project; -import com.syncd.domain.user.User; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.junit.jupiter.api.Assertions.*; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class ReadProjectPortTest { - private ReadProjectPort readProjectPort = Mockito.mock(ReadProjectPort.class); - - private Project project; - @BeforeEach - void setUp(){ - String hostId = "hostUserId"; - List emptyUserList = new ArrayList<>(); - project = new Project(); - project = project.createProjectDomain("Project Name", "Description", "img", hostId); - project.setId("1"); - project.setLastModifiedDate(LocalDateTime.now().toString()); - project.setProgress(0); - } - - @Test - void findAllProjectsByUserIdShouldReturnNonEmptyList() { - when(readProjectPort.findAllProjectByUserId(anyString())).thenReturn(Collections.singletonList(project)); - - List projects = readProjectPort.findAllProjectByUserId("user123"); - assertFalse(projects.isEmpty(), "findAllProjectByUserId should return a non-empty list"); - assertEquals("1", projects.get(0).getId(), "The returned project should have the correct ID"); - } - - @Test - void findAllProjectsByUserIdShouldHandleNoProjects() { - when(readProjectPort.findAllProjectByUserId(anyString())).thenReturn(Collections.emptyList()); - - List projects = readProjectPort.findAllProjectByUserId("user123"); - assertTrue(projects.isEmpty(), "findAllProjectByUserId should handle cases with no projects"); - } - - @Test - void findProjectByProjectIdShouldReturnCorrectProject() { - when(readProjectPort.findProjectByProjectId(anyString())).thenReturn(project); - - Project foundProject = readProjectPort.findProjectByProjectId("1"); - assertNotNull(foundProject, "findProjectByProjectId should return a non-null project"); - assertEquals("1", foundProject.getId(), "The project should have the correct ID"); - } - - @Test - void findProjectByProjectIdShouldHandleNotFound() { - when(readProjectPort.findProjectByProjectId(anyString())).thenReturn(null); - - Project project = readProjectPort.findProjectByProjectId("nonexistent"); - assertNull(project, "findProjectByProjectId should handle not found projects correctly"); - } -} \ No newline at end of file diff --git a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java b/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java deleted file mode 100644 index d7c2e55..0000000 --- a/src/test/java/application/port/out/persistence/project/WriteProjectPortTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package application.port.out.persistence.project; - -import com.syncd.application.port.out.persistence.project.WriteProjectPort; -import com.syncd.domain.project.Project; -import com.syncd.domain.user.User; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -public class WriteProjectPortTest { - private WriteProjectPort writeProjectPort = Mockito.mock(WriteProjectPort.class); - private Project project; - @BeforeEach - void setUp(){ - String hostId = "hostUserId"; - project = new Project(); - project = project.createProjectDomain("Project Name", "Description", "img", hostId); - project.setId("1"); - } - @Test - void createProjectShouldReturnNonNullId() { - Mockito.when(writeProjectPort.CreateProject(project)).thenReturn("1"); - String projectId = writeProjectPort.CreateProject(project); - assertNotNull(projectId, "CreateProject should return a non-null ID"); - } - - @Test - void updateProjectShouldReturnUpdatedId() { - Mockito.when(writeProjectPort.UpdateProject(project)).thenReturn("1"); - String updatedProjectId = writeProjectPort.UpdateProject(project); - assertEquals("1", updatedProjectId, "UpdateProject should return the updated project ID"); - } - - @Test - void removeProjectShouldNotThrowException() { - assertDoesNotThrow(() -> writeProjectPort.RemoveProject("1"), "RemoveProject should not throw exception"); - } - - @Test - void addProgressShouldReturnProjectId() { - Mockito.when(writeProjectPort.AddProgress("1", 50)).thenReturn("1"); - String projectId = writeProjectPort.AddProgress("1", 50); - assertEquals("1", projectId, "AddProgress should return the project ID"); - } - - @Test - void updateLastModifiedDateShouldReturnProjectId() { - Mockito.when(writeProjectPort.updateLastModifiedDate("1")).thenReturn("1"); - String projectId = writeProjectPort.updateLastModifiedDate("1"); - assertEquals("1", projectId, "updateLastModifiedDate should return the project ID"); - } -} diff --git a/src/test/java/application/port/out/persistence/user/ReadUserPortTest.java b/src/test/java/application/port/out/persistence/user/ReadUserPortTest.java deleted file mode 100644 index 1fba0d3..0000000 --- a/src/test/java/application/port/out/persistence/user/ReadUserPortTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package application.port.out.persistence.user; - -import com.syncd.application.port.out.persistence.user.ReadUserPort; -import com.syncd.domain.user.User; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import static org.mockito.Mockito.when; -import static org.junit.jupiter.api.Assertions.*; - -public class ReadUserPortTest{ - private ReadUserPort readUserPort = Mockito.mock(ReadUserPort.class); - - @Test - void findByEmailShouldReturnCorrectUser() { - User user = new User(); - user.setId("1"); - user.setName("User Name"); - user.setEmail("user@example.com"); - user.setProfileImg("profileImg.jpg"); - when(readUserPort.findByEmail("user@example.com")).thenReturn(user); - - User foundUser = readUserPort.findByEmail("user@example.com"); - assertNotNull(foundUser, "findByEmail should return a non-null user"); - assertEquals("user@example.com", foundUser.getEmail(), "The user should have the correct email"); - } - - @Test - void findByUsernameShouldReturnCorrectUser() { - User user = new User(); - user.setId("2"); - user.setName("Another User"); - user.setEmail("another@example.com"); - user.setProfileImg("anotherProfile.jpg"); - - when(readUserPort.findByUsername("Another User")).thenReturn(user); - - User foundUser = readUserPort.findByUsername("Another User"); - assertNotNull(foundUser, "findByUsername should return a non-null user"); - assertEquals("Another User", foundUser.getName(), "The user should have the correct username"); - } - - @Test - void findByUserIdShouldReturnCorrectUser() { - User user = new User(); - user.setId("1"); - user.setName("User Name"); - user.setEmail("user@example.com"); - user.setProfileImg("profileImg.jpg"); - when(readUserPort.findByUserId("1")).thenReturn(user); - - User foundUser = readUserPort.findByUserId("1"); - assertNotNull(foundUser, "findByUserId should return a non-null user"); - assertEquals("1", foundUser.getId(), "The user should have the correct user ID"); - } - - @Test - void isExistUserShouldReturnTrueForExistingUser() { - when(readUserPort.isExistUser("user@example.com")).thenReturn(true); - - boolean exists = readUserPort.isExistUser("user@example.com"); - assertTrue(exists, "isExistUser should return true for an existing user"); - } - - @Test - void isExistUserShouldReturnFalseForNonExistingUser() { - when(readUserPort.isExistUser("nonexistent@example.com")).thenReturn(false); - - boolean exists = readUserPort.isExistUser("nonexistent@example.com"); - assertFalse(exists, "isExistUser should return false for a non-existing user"); - } -} diff --git a/src/test/java/application/port/out/persistence/user/WriteUserPortTest.java b/src/test/java/application/port/out/persistence/user/WriteUserPortTest.java deleted file mode 100644 index 9db4086..0000000 --- a/src/test/java/application/port/out/persistence/user/WriteUserPortTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package application.port.out.persistence.user; - -import com.syncd.application.port.out.persistence.user.WriteUserPort; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; -import com.syncd.domain.user.User; -import com.syncd.dto.UserId; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.junit.jupiter.api.Assertions.*; - -public class WriteUserPortTest { - private WriteUserPort writeUserPort = Mockito.mock(WriteUserPort.class); - - @Test - void createUserShouldReturnValidUserId() { - when(writeUserPort.createUser("Alice", "alice@example.com", "image.jpg")).thenReturn(new UserId("1")); - - UserId userId = writeUserPort.createUser("Alice", "alice@example.com", "image.jpg"); - assertNotNull(userId, "createUser should return a non-null UserId"); - assertEquals("1", userId.value(), "The UserId should contain the correct ID"); - } - - @Test - void updateUserShouldReturnUpdatedUserId() { - User user = new User(); - user.setId("1"); - user.setName("Alice"); - user.setEmail("alice@example.com"); - user.setProfileImg("image.jpg"); - - when(writeUserPort.updateUser(any(User.class))).thenReturn(new UserId("1")); - - UserId updatedUserId = writeUserPort.updateUser(user); - assertNotNull(updatedUserId, "updateUser should return a non-null UserId"); - assertEquals("1", updatedUserId.value(), "The updated UserId should contain the correct ID"); - } -} diff --git a/src/test/java/application/service/LoginServiceTest.java b/src/test/java/application/service/LoginServiceTest.java deleted file mode 100644 index a820a53..0000000 --- a/src/test/java/application/service/LoginServiceTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package application.service; - -import com.syncd.application.port.in.GenerateTokenUsecase; -import com.syncd.application.port.out.persistence.user.ReadUserPort; -import com.syncd.application.port.out.persistence.user.WriteUserPort; -import com.syncd.application.service.LoginService; -import com.syncd.dto.TokenDto; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.web.client.RestTemplate; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - -public class LoginServiceTest { - - @Mock - private RestTemplate restTemplate; - - @Mock - private WriteUserPort writeUserPort; - - @Mock - private GenerateTokenUsecase generateTokenUsecase; - - @Mock - private ReadUserPort readUserPort; - - @InjectMocks - private LoginService loginService; - - @BeforeEach - void setUp() { - MockitoAnnotations.initMocks(this); - } - - @Test - void testSocialLoginWithGoogleRegistrationId() { - // Given - String authorizationCode = "sample_authorization_code"; - String registrationId = "google"; - - // Mocking the behavior of restTemplate.exchange(...) method - // Here you should mock the behavior to return a proper response entity - // For simplicity, let's assume it returns a valid response node - // when calling with the given parameters - // For actual implementation, you should define the behavior according to your needs - // For example: - // when(restTemplate.exchange(anyString(), any(), any(), eq(JsonNode.class))) - // .thenReturn(new ResponseEntity<>(mockResponseNode, HttpStatus.OK)); - - // When - TokenDto tokenDto = loginService.socialLogin(authorizationCode, registrationId); - - // Then - // Assert that the returned tokenDto is not null - // Add more assertions based on your actual implementation - // For example, you can assert that the generated access token is not empty - assertEquals("google", registrationId); - // Add more assertions based on your actual implementation - } -} diff --git a/src/test/java/application/service/ProjectServiceTest.java b/src/test/java/application/service/ProjectServiceTest.java index 2b0e9d2..a4e5428 100644 --- a/src/test/java/application/service/ProjectServiceTest.java +++ b/src/test/java/application/service/ProjectServiceTest.java @@ -1,2 +1,270 @@ -package application.service;public class ProjectServiceTest { +package application.service; + +import Dummy.Consistent; +import Dummy.Stub.application.out.gmail.StubSendMailPort; +import Dummy.Stub.application.out.liveblock.StubLiveblocksPort; +import Dummy.Stub.application.out.openai.StubChatGPTPort; +import Dummy.Stub.application.out.persistence.project.StubReadProjectPort; +import Dummy.Stub.application.out.persistence.project.StubWriteProjectPort; +import Dummy.Stub.application.out.persistence.user.StubReadUserPort; +import Dummy.Stub.application.out.s3.StubS3Port; +import Dummy.domain.MockProject; +import com.syncd.application.port.in.*; +import com.syncd.application.port.out.persistence.project.ReadProjectPort; +import com.syncd.application.port.out.persistence.project.WriteProjectPort; +import com.syncd.application.port.out.persistence.user.ReadUserPort; +import com.syncd.application.port.out.gmail.SendMailPort; +import com.syncd.application.port.out.liveblock.LiveblocksPort; +import com.syncd.application.port.out.openai.ChatGPTPort; +import com.syncd.application.port.out.s3.S3Port; +import com.syncd.application.service.ProjectService; +import com.syncd.domain.project.Project; +import com.syncd.domain.project.UserInProject; +import com.syncd.domain.user.User; +import com.syncd.dto.*; +import com.syncd.enums.Role; +import com.syncd.mapper.ProjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class ProjectServiceTest { + + @InjectMocks + private ProjectService projectService; + + private StubReadProjectPort readProjectPort; + + private StubWriteProjectPort writeProjectPort; + + private StubReadUserPort readUserPort; + + private StubLiveblocksPort liveblocksPort; + + private StubSendMailPort sendMailPort; + + private StubChatGPTPort chatGPTPort; + + private StubS3Port s3Port; + + private ProjectMapper projectMapper; + + @BeforeEach + void setUp() { + readProjectPort = new StubReadProjectPort(); + writeProjectPort = new StubWriteProjectPort(); + readUserPort = new StubReadUserPort(); + liveblocksPort = new StubLiveblocksPort(); + sendMailPort = new StubSendMailPort(); + chatGPTPort = new StubChatGPTPort(); + s3Port = new StubS3Port(); + projectMapper = Mockito.mock(ProjectMapper.class); + + projectService = new ProjectService(readProjectPort, writeProjectPort, readUserPort, liveblocksPort, sendMailPort, chatGPTPort, s3Port, projectMapper); + } + + + @Test + void createProject() { + // Given + String hostId = Consistent.UserId.getValue(); + String hostName = Consistent.UserName.getValue(); + String projectName = Consistent.ProjectName.getValue(); + String description = "description"; + MultipartFile img = mock(MultipartFile.class); + List userEmails = Arrays.asList("user1@example.com", "user2@example.com"); + + User host = new User(); + host.setId(hostId); + host.setName(hostName); + + Project project = new MockProject(); + project.setId(Consistent.ProjectId.getValue()); + + // When + CreateProjectUsecase.CreateProjectResponseDto response = projectService.createProject(hostId, hostName, projectName, description, img, userEmails); + + // Then + assertNotNull(response); + assertEquals(Consistent.ProjectId.getValue(), response.projectId()); + } + + @Test + void joinProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + + Project project = new MockProject(); + project.setId(projectId); + project.setUsers(Collections.singletonList(new UserInProject(userId, Role.MEMBER))); + + // When + JoinProjectUsecase.JoinProjectResponseDto response = projectService.joinProject(userId, projectId); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void getAllRoomsByUserId() { + // Given + String userId = Consistent.UserId.getValue(); + + Project project = new MockProject(); + project.setId(Consistent.ProjectId.getValue()); + + // When + GetAllRoomsByUserIdUsecase.GetAllRoomsByUserIdResponseDto response = projectService.getAllRoomsByUserId(userId); + + // Then + assertNotNull(response); + assertEquals(userId, response.userId()); + assertFalse(response.projects().isEmpty()); + } + + @Test + void getRoomAuthToken() { + // Given + String userId = Consistent.UserId.getValue(); + + User user = new User(); + user.setId(userId); + user.setName(Consistent.UserName.getValue()); + + Project project = new MockProject(); + project.setId(Consistent.ProjectId.getValue()); + + // When + GetRoomAuthTokenUsecase.GetRoomAuthTokenResponseDto response = projectService.getRoomAuthToken(userId); + + // Then + assertNotNull(response); + assertEquals(Consistent.LiveblocksToken.getValue(), response.token()); + } + + @Test + void deleteProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + + Project project = new MockProject(); + project.setId(projectId); + project.setImg("https://example.com/dummyimage.jpg"); + + // When + DeleteProjectUsecase.DeleteProjectResponseDto response = projectService.deleteProject(userId, projectId); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void inviteUserInProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + List userEmails = Arrays.asList("user1@example.com", "user2@example.com"); + + Project project = new MockProject(); + project.setId(projectId); + + User host = new User(); + host.setId(userId); + + // When + InviteUserInProjectUsecase.InviteUserInProjectResponseDto response = projectService.inviteUserInProject(userId, projectId, userEmails); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void updateProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + String projectName = Consistent.ProjectName.getValue(); + String description = "updatedDescription"; + String image = "updatedImage"; + + Project project = new MockProject(); + project.setId(projectId); + + // When + UpdateProjectUsecase.UpdateProjectResponseDto response = projectService.updateProject(userId, projectId, projectName, description, image); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void withdrawUserInProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + List userIds = Arrays.asList("user1", "user2"); + + Project project = new MockProject(); + project.setId(projectId); + project.setUsers(Arrays.asList( + new UserInProject("user1", Role.MEMBER), + new UserInProject("user2", Role.MEMBER), + new UserInProject("user3", Role.HOST) + )); + // When + WithdrawUserInProjectUsecase.WithdrawUserInProjectResponseDto response = projectService.withdrawUserInProject(userId, projectId, userIds); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void syncProject() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + int projectStage = 1; + + Project project = new MockProject(); + project.setId(projectId); + + // When + SyncProjectUsecase.SyncProjectResponseDto response = projectService.syncProject(userId, projectId, projectStage); + + // Then + assertNotNull(response); + assertEquals(projectId, response.projectId()); + } + + @Test + void makeUserstory() { + // Given + String userId = Consistent.UserId.getValue(); + String projectId = Consistent.ProjectId.getValue(); + List scenarios = Arrays.asList("scenario1", "scenario2"); + + // When + MakeUserStoryResponseDto response = projectService.makeUserstory(userId, projectId, scenarios); + + // Then + assertNotNull(response); + assertEquals(new MakeUserStoryResponseDto(), response); + } } From 1f1396c1258f703bc434f95a5ff337b94fcf0675 Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 2 Jun 2024 15:21:46 +0900 Subject: [PATCH 20/21] test: Modify test code --- .../adaptor/in/web/AuthControllerTest.java | 13 ++++--- .../adaptor/in/web/LoginControllerTest.java | 38 +++++++++--------- .../service/ProjectServiceTest.java | 39 ------------------- 3 files changed, 26 insertions(+), 64 deletions(-) diff --git a/src/test/java/adaptor/in/web/AuthControllerTest.java b/src/test/java/adaptor/in/web/AuthControllerTest.java index 4732e55..077880f 100644 --- a/src/test/java/adaptor/in/web/AuthControllerTest.java +++ b/src/test/java/adaptor/in/web/AuthControllerTest.java @@ -3,15 +3,13 @@ import com.syncd.AuthControllerProperties; import com.syncd.adapter.in.web.AuthController; import com.syncd.application.port.in.SocialLoginUsecase; +import com.syncd.application.port.out.persistence.user.ReadUserPort; import com.syncd.dto.TokenDto; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.*; import org.springframework.web.servlet.view.RedirectView; import static org.assertj.core.api.Assertions.assertThat; @@ -26,12 +24,15 @@ public class AuthControllerTest { @Mock private AuthControllerProperties authControllerProperties; - @InjectMocks private AuthController authController; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + socialLoginUsecase = Mockito.mock(SocialLoginUsecase.class); + authControllerProperties = Mockito.mock(AuthControllerProperties.class); + + authController = new AuthController(socialLoginUsecase,authControllerProperties); } @Test @@ -44,7 +45,7 @@ void testGoogleLogin_ValidRequest() { String refreshToken = "refreshToken"; when(authControllerProperties.getRedirectUrl()).thenReturn(redirectUrl); - when(socialLoginUsecase.socialLogin(anyString(), anyString(),anyString())).thenReturn(new TokenDto(accessToken, refreshToken)); + when(socialLoginUsecase.socialLogin(anyString(), anyString(),any())).thenReturn(new TokenDto(accessToken, refreshToken)); HttpServletResponse response = mock(HttpServletResponse.class); RedirectView result = authController.googleLogin(code, registrationId, response); diff --git a/src/test/java/adaptor/in/web/LoginControllerTest.java b/src/test/java/adaptor/in/web/LoginControllerTest.java index 6977338..df965a3 100644 --- a/src/test/java/adaptor/in/web/LoginControllerTest.java +++ b/src/test/java/adaptor/in/web/LoginControllerTest.java @@ -27,25 +27,25 @@ void setUp() { MockitoAnnotations.openMocks(this); } - @Test - @DisplayName("Test Redirect to Google OAuth - Valid Request") - void testRedirectToGoogleOAuth_ValidRequest() { - String redirectUri = "http://localhost:8080/redirect"; - when(googleOAuth2Properties.getRedirectUri()).thenReturn(redirectUri); - - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getHeader("Referer")).thenReturn("http://localhost:8080/"); - - RedirectView result = loginController.redirectToGoogleOAuth(request); - - String expectedUrl = "https://accounts.google.com/o/oauth2/auth" + - "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + - "&redirect_uri=http://localhost:8080/login/oauth2/code/google" + - "&response_type=code" + - "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; - - assertThat(result.getUrl()).isEqualTo(expectedUrl); - } +// @Test +// @DisplayName("Test Redirect to Google OAuth - Valid Request") +// void testRedirectToGoogleOAuth_ValidRequest() { +// String redirectUri = "http://localhost:8080/redirect"; +// when(googleOAuth2Properties.getRedirectUri()).thenReturn(redirectUri); +// +// HttpServletRequest request = mock(HttpServletRequest.class); +// +// when(request.getHeader("Referer")).thenReturn("http://localhost:8080/"); +// RedirectView result = loginController.redirectToGoogleOAuth(request); +// +// String expectedUrl = "https://accounts.google.com/o/oauth2/auth" + +// "?client_id=70988875044-9nmbvd2suleub4ja095mrh83qbi7140j.apps.googleusercontent.com" + +// "&redirect_uri=http://localhost:8080/login/oauth2/code/google" + +// "&response_type=code" + +// "&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; +// +// assertThat(result.getUrl()).isEqualTo(expectedUrl); +// } // @Test // @DisplayName("Test Redirect to Google OAuth - No Referer Header") diff --git a/src/test/java/application/service/ProjectServiceTest.java b/src/test/java/application/service/ProjectServiceTest.java index a4e5428..d136179 100644 --- a/src/test/java/application/service/ProjectServiceTest.java +++ b/src/test/java/application/service/ProjectServiceTest.java @@ -84,13 +84,6 @@ void createProject() { MultipartFile img = mock(MultipartFile.class); List userEmails = Arrays.asList("user1@example.com", "user2@example.com"); - User host = new User(); - host.setId(hostId); - host.setName(hostName); - - Project project = new MockProject(); - project.setId(Consistent.ProjectId.getValue()); - // When CreateProjectUsecase.CreateProjectResponseDto response = projectService.createProject(hostId, hostName, projectName, description, img, userEmails); @@ -105,10 +98,6 @@ void joinProject() { String userId = Consistent.UserId.getValue(); String projectId = Consistent.ProjectId.getValue(); - Project project = new MockProject(); - project.setId(projectId); - project.setUsers(Collections.singletonList(new UserInProject(userId, Role.MEMBER))); - // When JoinProjectUsecase.JoinProjectResponseDto response = projectService.joinProject(userId, projectId); @@ -122,9 +111,6 @@ void getAllRoomsByUserId() { // Given String userId = Consistent.UserId.getValue(); - Project project = new MockProject(); - project.setId(Consistent.ProjectId.getValue()); - // When GetAllRoomsByUserIdUsecase.GetAllRoomsByUserIdResponseDto response = projectService.getAllRoomsByUserId(userId); @@ -143,9 +129,6 @@ void getRoomAuthToken() { user.setId(userId); user.setName(Consistent.UserName.getValue()); - Project project = new MockProject(); - project.setId(Consistent.ProjectId.getValue()); - // When GetRoomAuthTokenUsecase.GetRoomAuthTokenResponseDto response = projectService.getRoomAuthToken(userId); @@ -160,10 +143,6 @@ void deleteProject() { String userId = Consistent.UserId.getValue(); String projectId = Consistent.ProjectId.getValue(); - Project project = new MockProject(); - project.setId(projectId); - project.setImg("https://example.com/dummyimage.jpg"); - // When DeleteProjectUsecase.DeleteProjectResponseDto response = projectService.deleteProject(userId, projectId); @@ -179,11 +158,6 @@ void inviteUserInProject() { String projectId = Consistent.ProjectId.getValue(); List userEmails = Arrays.asList("user1@example.com", "user2@example.com"); - Project project = new MockProject(); - project.setId(projectId); - - User host = new User(); - host.setId(userId); // When InviteUserInProjectUsecase.InviteUserInProjectResponseDto response = projectService.inviteUserInProject(userId, projectId, userEmails); @@ -202,9 +176,6 @@ void updateProject() { String description = "updatedDescription"; String image = "updatedImage"; - Project project = new MockProject(); - project.setId(projectId); - // When UpdateProjectUsecase.UpdateProjectResponseDto response = projectService.updateProject(userId, projectId, projectName, description, image); @@ -220,13 +191,6 @@ void withdrawUserInProject() { String projectId = Consistent.ProjectId.getValue(); List userIds = Arrays.asList("user1", "user2"); - Project project = new MockProject(); - project.setId(projectId); - project.setUsers(Arrays.asList( - new UserInProject("user1", Role.MEMBER), - new UserInProject("user2", Role.MEMBER), - new UserInProject("user3", Role.HOST) - )); // When WithdrawUserInProjectUsecase.WithdrawUserInProjectResponseDto response = projectService.withdrawUserInProject(userId, projectId, userIds); @@ -242,9 +206,6 @@ void syncProject() { String projectId = Consistent.ProjectId.getValue(); int projectStage = 1; - Project project = new MockProject(); - project.setId(projectId); - // When SyncProjectUsecase.SyncProjectResponseDto response = projectService.syncProject(userId, projectId, projectStage); From 15631e2bcb5644a0d471fdcca56527077bda93cb Mon Sep 17 00:00:00 2001 From: donggni0712 Date: Sun, 2 Jun 2024 16:31:06 +0900 Subject: [PATCH 21/21] =?UTF-8?q?feat:=20=EC=B4=88=EB=8C=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../syncd/adapter/out/gmail/GmailAdapter.java | 14 +++---- .../port/out/gmail/SendMailPort.java | 2 +- .../syncd/application/service/JwtService.java | 3 +- .../application/service/ProjectService.java | 42 +++++++++++-------- .../com/syncd/domain/project/Project.java | 1 + .../out/gmail/StubSendMailPort.java | 2 +- 6 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java b/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java index cf1f079..1d5528b 100644 --- a/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java +++ b/src/main/java/com/syncd/adapter/out/gmail/GmailAdapter.java @@ -20,7 +20,7 @@ public class GmailAdapter implements SendMailPort { @Override @Async public String sendInviteMail(String email, String hostName, String userName, String projectName, String projectId) { - MimeMessage message = createMail(email, hostName, userName, projectName,projectId); + MimeMessage message = createMail(email, hostName, projectName,projectId); // 실제 메일 전송 javaMailSender.send(message); @@ -28,16 +28,16 @@ public String sendInviteMail(String email, String hostName, String userName, Str } @Override - public String sendIviteMailBatch(String hostName, String projectName, List users,String projectId) { - users.forEach(user -> { - MimeMessage message = createMail(user.getEmail(), hostName, user.getName(), projectName,projectId); + public String sendIviteMailBatch(String hostName, String projectName, List userEmails,String projectId) { + userEmails.forEach(user -> { + MimeMessage message = createMail(user, hostName, projectName,projectId); javaMailSender.send(message); }); return projectName; } // 메일 양식 작성 - public MimeMessage createMail(String email, String hostName, String userName, String projectName, String projectId){ + public MimeMessage createMail(String email, String hostName, String projectName, String projectId){ MimeMessage message = javaMailSender.createMimeMessage(); try { @@ -85,8 +85,8 @@ public MimeMessage createMail(String email, String hostName, String userName, St "\n" + "

\n" + "

싱크대 프로젝트에 초대받았습니다!

\n" + - "

"+hostName+"님이 "+userName+"님을 "+projectName+" 프로젝트에 초대되었습니다. 아래 버튼을 클릭하시면 초대에 응하실 수 있습니다

\n" + - " 초대 수락\n" + + "

"+hostName+"님이 "+projectName+" 프로젝트에 초대하였습니다. 아래 버튼을 클릭하여 협업하세요!

\n" + + " sync-d 접속\n" + "
\n" + "\n" + "\n"; diff --git a/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java b/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java index 6caa26d..60c31da 100644 --- a/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java +++ b/src/main/java/com/syncd/application/port/out/gmail/SendMailPort.java @@ -9,5 +9,5 @@ public interface SendMailPort { @Async String sendInviteMail(String email,String hostName, String userName, String projectName, String ProjectId); - String sendIviteMailBatch(String hostName, String projectName, List users, String ProjectId); + String sendIviteMailBatch(String hostName, String projectName, List userEmails, String ProjectId); } diff --git a/src/main/java/com/syncd/application/service/JwtService.java b/src/main/java/com/syncd/application/service/JwtService.java index e0e93a8..346b26f 100644 --- a/src/main/java/com/syncd/application/service/JwtService.java +++ b/src/main/java/com/syncd/application/service/JwtService.java @@ -78,7 +78,8 @@ private static Key createSigningKey() { // } @Override public String getUsernameFromToken(String token) { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); + return claims.get("name", String.class); } @Override public boolean validateToken(String token) { diff --git a/src/main/java/com/syncd/application/service/ProjectService.java b/src/main/java/com/syncd/application/service/ProjectService.java index a61d63b..67b412d 100644 --- a/src/main/java/com/syncd/application/service/ProjectService.java +++ b/src/main/java/com/syncd/application/service/ProjectService.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -46,11 +47,8 @@ public class ProjectService implements CreateProjectUsecase, GetAllRoomsByUserId @Override public CreateProjectResponseDto createProject(String hostId, String hostName, String projectName, String description, MultipartFile img, List userEmails){ - List users = new ArrayList<>(); - if(userEmails!=null){ - users = readUserPort.usersFromEmails(userEmails); - } String imgURL = ""; + System.out.println(hostName); if (img != null && !img.isEmpty()) { Optional optionalImgURL = s3Port.uploadMultipartFileToS3(img, hostName, projectName); imgURL = optionalImgURL.orElseThrow(() -> new IllegalStateException("Failed to upload image to S3")); @@ -58,21 +56,27 @@ public CreateProjectResponseDto createProject(String hostId, String hostName, St Project project = new Project(); project = project.createProjectDomain(projectName, description, imgURL, hostId); - + project.addUsers(userInProjectFromEmail(userEmails)); CreateProjectResponseDto createProjectResponseDto = new CreateProjectResponseDto(writeProjectPort.CreateProject(project)); User host = readUserPort.findByUserId(hostId); - List members = new ArrayList<>(); - if (userEmails != null && !userEmails.isEmpty()) { - members = userEmails.stream() - .map(email -> createUserInProjectWithRoleMember(email, host.getName(), projectName, createProjectResponseDto.projectId())) - .collect(Collectors.toList()); - } - - sendMailPort.sendIviteMailBatch(hostName, projectName, users,project.getId()); +// List members = new ArrayList<>(); +// if (userEmails != null && !userEmails.isEmpty()) { +// members = userEmails.stream() +// .map(email -> createUserInProjectWithRoleMember(email, host.getName(), projectName, createProjectResponseDto.projectId())) +// .collect(Collectors.toList()); +// } + + sendMailPort.sendIviteMailBatch(hostName, projectName, userEmails, project.getId()); return createProjectResponseDto; } + private List userInProjectFromEmail(List userEmails) { + return userEmails.stream() + .map(email -> new UserInProject(email, Role.MEMBER)) + .collect(Collectors.toList()); + } + @Override public JoinProjectUsecase.JoinProjectResponseDto joinProject(String userId, String projectId) { UserInProject userInProject = new UserInProject(userId, Role.MEMBER); @@ -221,9 +225,9 @@ private GetAllRoomsByUserIdResponseDto mapProjectsToResponse(String userId, List return new GetAllRoomsByUserIdResponseDto(userId, projectDtos); } - private ProjectForGetAllInfoAboutRoomsByUserIdResponseDto convertProjectToDto(String userId, Project project) { + private ProjectForGetAllInfoAboutRoomsByUserIdResponseDto convertProjectToDto(String userEmail, Project project) { Role userRole = project.getUsers().stream() - .filter(user -> userId.equals(user.getUserId())) + .filter(user -> userEmail.equals(user.getUserId())) .map(UserInProject::getRole) .findFirst() .orElse(null); @@ -234,9 +238,11 @@ private ProjectForGetAllInfoAboutRoomsByUserIdResponseDto convertProjectToDto(St List userEmails = usersInProject.stream() .map(UserInProject::getUserId) - .map(readUserPort::findByUserId) - .filter(user -> user != null) - .map(User::getEmail) + .map(userId -> { + User user = readUserPort.findByUserId(userId); + return user != null ? user.getEmail() : null; + }) + .filter(Objects::nonNull) .collect(Collectors.toList()); return new ProjectForGetAllInfoAboutRoomsByUserIdResponseDto( diff --git a/src/main/java/com/syncd/domain/project/Project.java b/src/main/java/com/syncd/domain/project/Project.java index 35dada3..d3537fd 100644 --- a/src/main/java/com/syncd/domain/project/Project.java +++ b/src/main/java/com/syncd/domain/project/Project.java @@ -23,6 +23,7 @@ public class Project { private String lastModifiedDate; private int leftChanceForUserstory; + public void addUsers(List newUsers) { if (this.users == null) { this.users = new ArrayList<>(); diff --git a/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java b/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java index 437da7c..1474e05 100644 --- a/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java +++ b/src/test/java/Dummy/Stub/application/out/gmail/StubSendMailPort.java @@ -13,7 +13,7 @@ public String sendInviteMail(String email, String hostName, String userName, Str } @Override - public String sendIviteMailBatch(String hostName, String projectName, List users, String ProjectId) { + public String sendIviteMailBatch(String hostName, String projectName, List userEmails, String ProjectId) { return Consistent.ProjectName.getValue(); } }