diff --git a/README.md b/README.md index bfe18d0..6a5c981 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ -# task-buddy \ No newline at end of file +# task-buddy + + +## Git +### Work Flow +1. Issue 발생하여 TODO 등록 +2. feature 브랜치 생성 (브랜치명: `feature/{Issue 번호}-{작업타이틀}`) +3. PR 제출 (제목: `[{issue 번호}] {작업 주제}`) diff --git a/api/build.gradle b/api/build.gradle index a729d37..1560aaf 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.6' + id "io.freefair.lombok" version "8.7.1" + id "org.asciidoctor.jvm.convert" version "3.3.2" } java { @@ -15,12 +17,34 @@ repositories { mavenCentral() } +configurations { + asciidoctorExt +} + dependencies { implementation project(':core') implementation 'org.springframework.boot:spring-boot-starter-web' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' +} + +ext { + snippetsDir = file('build/generated-snippets') } test { useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn test +} + +bootJar { + dependsOn asciidoctor } diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..724706f --- /dev/null +++ b/api/src/docs/asciidoc/index.adoc @@ -0,0 +1,10 @@ += Api Document +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 1 +:sectlinks: +:docinfo: shared-head + +include::/{docfile}/../task.adoc[] diff --git a/api/src/docs/asciidoc/task.adoc b/api/src/docs/asciidoc/task.adoc new file mode 100644 index 0000000..1796bf7 --- /dev/null +++ b/api/src/docs/asciidoc/task.adoc @@ -0,0 +1,58 @@ +== 1. Task + +[[resources-tasks-get]] +=== 1) Task 조회 + +`GET` 요청을 사용해서 기존 Task 하나를 조회할 수 있다. + +==== 성공 응답 +operation::get-a-task-with-id/success[snippets='request-headers,path-parameters,http-request,response-headers,response-fields,http-response'] + +==== Task가 존재하지 않을 경우 +operation::get-a-task-with-id/fail/does-not-exist[snippets='http-request,http-response'] + +==== 잘못된 형식의 ID로 요청한 경우 +operation::get-a-task-with-id/fail/negative-id-value[snippets='http-request,http-response'] + + +[[resources-tasks-create]] +=== 2) Task 생성 + +`POST` 요청을 사용해서 새 Task를 만들 수 있다. + +==== 성공 응답 +operation::create-a-task/success[snippets='request-headers,request-fields,http-request,response-headers,response-fields,http-response'] + +==== Request 조건에 만족하지 않을 경우 +operation::create-a-task/fail/invalid-request-data[snippets='http-request,http-response'] + +[[resources-tasks-update]] +=== 3) Task 수정 + +`PATCH` 요청을 사용해서 기존 Task를 수정할 수 있다. + +==== 성공 응답 +operation::update-a-task/success[snippets='request-headers,path-parameters,request-fields,http-request,response-headers,response-fields,http-response'] + +==== 잘못된 형식의 ID로 요청한 경우 +operation::update-a-task/fail/negative-id-value[snippets='http-request,http-response'] + +==== Request 조건에 만족하지 않을 경우 +operation::update-a-task/fail/invalid-request-data[snippets='http-request,http-response'] + +==== Task가 존재하지 않을 경우 +operation::update-a-task/fail/does-not-exist[snippets='http-request,http-response'] + +[[resources-tasks-remove]] +=== 4) Task 삭제 + +`DELETE` 요청을 사용해서 기존 Task를 수정할 수 있다. + +==== 성공 응답 +operation::remove-a-task/success[snippets='request-headers,path-parameters,http-request,response-headers,response-fields,http-response'] + +==== 잘못된 형식의 ID로 요청한 경우 +operation::remove-a-task/fail/negative-id-value[snippets='http-request,http-response'] + +==== Task가 존재하지 않을 경우 +operation::remove-a-task/fail/does-not-exist[snippets='http-request,http-response'] diff --git a/api/src/main/java/com/taskbuddy/api/controller/ApiControllerAdvice.java b/api/src/main/java/com/taskbuddy/api/controller/ApiControllerAdvice.java new file mode 100644 index 0000000..94011ff --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/ApiControllerAdvice.java @@ -0,0 +1,26 @@ +package com.taskbuddy.api.controller; + +import com.taskbuddy.api.controller.response.ApiResponse; +import com.taskbuddy.api.controller.response.ErrorDetail; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ApiControllerAdvice { + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException exception) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.fail(new ErrorDetail("INVALID_PARAMETER_STATE", exception.getMessage()))); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException exception) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.fail(new ErrorDetail("INVALID_PARAMETER_STATE", exception.getMessage()))); + } +} diff --git a/api/src/main/java/com/taskbuddy/api/common/HealthController.java b/api/src/main/java/com/taskbuddy/api/controller/HealthController.java similarity index 82% rename from api/src/main/java/com/taskbuddy/api/common/HealthController.java rename to api/src/main/java/com/taskbuddy/api/controller/HealthController.java index 57c1d60..4209b2f 100644 --- a/api/src/main/java/com/taskbuddy/api/common/HealthController.java +++ b/api/src/main/java/com/taskbuddy/api/controller/HealthController.java @@ -1,4 +1,4 @@ -package com.taskbuddy.api.common; +package com.taskbuddy.api.controller; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; @@ -10,5 +10,8 @@ public class HealthController { @ResponseStatus(code = HttpStatus.OK) @GetMapping("/health-check") - public void healthCheck() {} + public void healthCheck() { + + } + } diff --git a/api/src/main/java/com/taskbuddy/api/controller/TaskController.java b/api/src/main/java/com/taskbuddy/api/controller/TaskController.java new file mode 100644 index 0000000..ab591f9 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/TaskController.java @@ -0,0 +1,77 @@ +package com.taskbuddy.api.controller; + +import com.taskbuddy.api.controller.request.TaskCreateRequest; +import com.taskbuddy.api.controller.request.TaskUpdateRequest; +import com.taskbuddy.api.controller.response.ApiResponse; +import com.taskbuddy.api.controller.response.task.TaskResponse; +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.time.LocalDateTime; + +@RequestMapping("/v1/tasks") +@RestController +public class TaskController { + @GetMapping("/{id}") + ResponseEntity> getTask(@PathVariable("id") Long id) { + Assert.state(id >= 0, "The id value must be positive."); + + // FIXME 서비스 로직 구현하면 제거하기 + if (id == 0) { + throw new IllegalArgumentException("The given task with id does not exist."); + } + + //Dummy + TaskResponse response = new TaskResponse( + 1L + , "알고리즘 문제 풀기" + , "백준1902..." + , false + , new TimeFrame( + LocalDateTime.of(2024, 8, 1, 0, 0, 0) + , LocalDateTime.of(2024, 8, 31, 23, 59, 59))); + + return ResponseEntity + .ok(ApiResponse.success(response)); + } + + @PostMapping + ResponseEntity> createTask(@RequestBody TaskCreateRequest request) { + //Dummy + final long createdTaskId = 1L; + + return ResponseEntity + .created(URI.create("localhost:8080/v1/tasks/" + createdTaskId)) + .body(ApiResponse.success()); + } + + @PatchMapping("/{id}") + ResponseEntity> updateTask(@PathVariable("id") Long id, @RequestBody TaskUpdateRequest request) { + Assert.state(id >= 0, "The id value must be positive."); + + // FIXME 서비스 로직 구현하면 제거하기 + if (id == 0) { + throw new IllegalArgumentException("The given task with id does not exist."); + } + + return ResponseEntity + .ok(ApiResponse.success()); + } + + @DeleteMapping("/{id}") + ResponseEntity> removeTask(@PathVariable("id") Long id) { + Assert.state(id >= 0, "The id value must be positive."); + + // FIXME 서비스 로직 구현하면 제거하기 + if (id == 0) { + throw new IllegalArgumentException("The given task with id does not exist."); + } + + return ResponseEntity + .ok(ApiResponse.success()); + } + +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/request/TaskCreateRequest.java b/api/src/main/java/com/taskbuddy/api/controller/request/TaskCreateRequest.java new file mode 100644 index 0000000..70e5bfa --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/request/TaskCreateRequest.java @@ -0,0 +1,16 @@ +package com.taskbuddy.api.controller.request; + +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.springframework.util.Assert; + +public record TaskCreateRequest( + String title, + String description, + TimeFrame timeFrame +) { + public TaskCreateRequest { + Assert.state(title != null && !title.isBlank(), "The title of task must not be blank."); + Assert.state(description == null || description.length() <= 500, "The description length must be equal or less than 500"); + Assert.notNull(timeFrame, "The timeFrame must not be null."); + } +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/request/TaskUpdateRequest.java b/api/src/main/java/com/taskbuddy/api/controller/request/TaskUpdateRequest.java new file mode 100644 index 0000000..4d478c1 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/request/TaskUpdateRequest.java @@ -0,0 +1,16 @@ +package com.taskbuddy.api.controller.request; + +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.springframework.util.Assert; + +public record TaskUpdateRequest( + String title, + String description, + TimeFrame timeFrame +) { + public TaskUpdateRequest { + Assert.state(title != null && !title.isBlank(), "The title of task must not be blank."); + Assert.state(description == null || description.length() <= 500, "The description length must be equal or less than 500"); + Assert.notNull(timeFrame, "The timeFrame must not be null."); + } +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/response/ApiResponse.java b/api/src/main/java/com/taskbuddy/api/controller/response/ApiResponse.java new file mode 100644 index 0000000..925cbe6 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/response/ApiResponse.java @@ -0,0 +1,33 @@ +package com.taskbuddy.api.controller.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; +import org.springframework.util.Assert; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Getter +public class ApiResponse { + private final ResultStatus status; + private final D data; + private final ErrorDetail error; + + private ApiResponse(ResultStatus status, D data, ErrorDetail error) { + this.status = status; + this.data = data; + this.error = error; + } + + public static ApiResponse success(D data) { + return new ApiResponse<>(ResultStatus.SUCCESS, data, null); + } + + public static ApiResponse success() { + return new ApiResponse<>(ResultStatus.SUCCESS, null, null); + } + + public static ApiResponse fail(ErrorDetail error) { + Assert.notNull(error, "The error argument must not be null."); + + return new ApiResponse<>(ResultStatus.FAIL, null, error); + } +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/response/ErrorDetail.java b/api/src/main/java/com/taskbuddy/api/controller/response/ErrorDetail.java new file mode 100644 index 0000000..ae348d7 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/response/ErrorDetail.java @@ -0,0 +1,4 @@ +package com.taskbuddy.api.controller.response; + +public record ErrorDetail(String code, String message) { +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/response/ResultStatus.java b/api/src/main/java/com/taskbuddy/api/controller/response/ResultStatus.java new file mode 100644 index 0000000..60acb14 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/response/ResultStatus.java @@ -0,0 +1,6 @@ +package com.taskbuddy.api.controller.response; + +public enum ResultStatus { + SUCCESS, + FAIL, +} diff --git a/api/src/main/java/com/taskbuddy/api/controller/response/task/TaskResponse.java b/api/src/main/java/com/taskbuddy/api/controller/response/task/TaskResponse.java new file mode 100644 index 0000000..6511f4c --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/response/task/TaskResponse.java @@ -0,0 +1,9 @@ +package com.taskbuddy.api.controller.response.task; + +public record TaskResponse ( + Long id, + String title, + String description, + Boolean isDone, + TimeFrame timeFrame +) {} diff --git a/api/src/main/java/com/taskbuddy/api/controller/response/task/TimeFrame.java b/api/src/main/java/com/taskbuddy/api/controller/response/task/TimeFrame.java new file mode 100644 index 0000000..4b04652 --- /dev/null +++ b/api/src/main/java/com/taskbuddy/api/controller/response/task/TimeFrame.java @@ -0,0 +1,15 @@ +package com.taskbuddy.api.controller.response.task; + +import org.springframework.util.Assert; + +import java.time.LocalDateTime; + +public record TimeFrame( + LocalDateTime startDateTime, + LocalDateTime endDateTime) { + public TimeFrame { + Assert.notNull(startDateTime, "The startDateTime must not be null."); + Assert.notNull(endDateTime, "The endDateTime must not be null."); + Assert.isTrue(endDateTime.isAfter(startDateTime), "The endDateTime must be after than the startDateTime."); + } +} diff --git a/api/src/test/java/com/taskbuddy/api/controller/TaskControllerTest.java b/api/src/test/java/com/taskbuddy/api/controller/TaskControllerTest.java new file mode 100644 index 0000000..8275a34 --- /dev/null +++ b/api/src/test/java/com/taskbuddy/api/controller/TaskControllerTest.java @@ -0,0 +1,358 @@ +package com.taskbuddy.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.taskbuddy.api.controller.request.TaskCreateRequest; +import com.taskbuddy.api.controller.request.TaskUpdateRequest; +import com.taskbuddy.api.controller.response.ResultStatus; +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +//TODO (#12) 태그로 테스트 분리 +@SpringBootTest +@ExtendWith({RestDocumentationExtension.class}) +public class TaskControllerTest { + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setup(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation) + .operationPreprocessors() + .withRequestDefaults(prettyPrint()) + .withResponseDefaults(prettyPrint())) + .alwaysDo(document("v1/tasks/")) + .build(); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.objectMapper = objectMapper; + } + + @Test + void 사용자는_Task_정보를_조회할_수_있다() throws Exception { + mockMvc.perform(get("/v1/tasks/{id}", 1L) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(ResultStatus.SUCCESS.name())) + .andExpect(jsonPath("$.data.id", allOf(notNullValue(), greaterThan(0)))) + .andExpect(jsonPath("$.data.title").exists()) + .andExpect(jsonPath("$.data.isDone").exists()) + .andExpect(jsonPath("$.data.timeFrame").exists()) + .andDo(document("get-a-task-with-id/success", + requestHeaders( + headerWithName(HttpHeaders.ACCEPT).description("accept header") + ), + pathParameters( + parameterWithName("id").description("task id") + ), + responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE).description("응답 헤더") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.STRING).description("The status of teh response, e.g.")) + .andWithPrefix("data.", + fieldWithPath("id").type(JsonFieldType.NUMBER).description("task id"), + fieldWithPath("title").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("description").type(JsonFieldType.STRING).description("추가 설명"), + fieldWithPath("isDone").type(JsonFieldType.BOOLEAN).description("완료 여부"), + fieldWithPath("timeFrame").type(JsonFieldType.OBJECT).description("수행기간"), + fieldWithPath("timeFrame.startDateTime").type(JsonFieldType.STRING).description("시작일시 (yyyy-MM-dd)"), + fieldWithPath("timeFrame.endDateTime").type(JsonFieldType.STRING).description("종료일시 (yyyy-MM-dd)")))); + } + + @Test + void 조회할_Task가_존재하지_않는다면_실패응답을_받는다() throws Exception { + mockMvc.perform(get("/v1/tasks/{id}", 0) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The given task with id does not exist.")) + .andDo(document("get-a-task-with-id/fail/does-not-exist")); + } + + @Test + void 조회할_Task_ID가_음수값이면_실패응답을_받는다() throws Exception { + mockMvc.perform(get("/v1/tasks/{id}", -1) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The id value must be positive.")) + .andDo(document("get-a-task-with-id/fail/negative-id-value")); + } + + @Test + void 사용자는_Task를_생성할_수_있다() throws Exception { + TaskCreateRequest request = new TaskCreateRequest( + "알고리즘 문제 풀기", + "백준1902..", + new TimeFrame( + LocalDateTime.of(2024, 8, 1, 0, 0, 0), + LocalDateTime.of(2024, 8, 1, 23, 59, 59))); + + mockMvc.perform(post("/v1/tasks") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(ResultStatus.SUCCESS.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andDo(document("create-a-task/success", + requestHeaders( + headerWithName(HttpHeaders.ACCEPT).description("accept header"), + headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("description").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("timeFrame").type(JsonFieldType.OBJECT).description("수행기간"), + fieldWithPath("timeFrame.startDateTime").type(JsonFieldType.STRING).description("시작일시 (yyyy-MM-dd)"), + fieldWithPath("timeFrame.endDateTime").type(JsonFieldType.STRING).description("종료일시 (yyyy-MM-dd)") + ), + responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE).description("response body content type"), + headerWithName(HttpHeaders.LOCATION).description("생성된 Task 조회 URL") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.STRING).description("성공 여부")))); + } + + @Test + void 등록데이터_조건을_만족하지_않는다면_실패응답을_받는다() throws Exception { + String emptyTitle = ""; + String json = "{\n" + + " \"title\":\" " + emptyTitle + " \",\n" + + " \"description\":\"백준1902..\",\n" + + " \"timeFrame\":{\n" + + " \"startDateTime\":\"2024-08-01T00:00:00\",\n" + + " \"endDateTime\":\"2024-08-01T23:59:59\"\n" + + " }\n" + + "}"; + + mockMvc.perform(post("/v1/tasks") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(json)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The title of task must not be blank.")) + .andDo(document("create-a-task/fail/invalid-request-data")) + ; + } + + @Test + void 사용자는_Task내용을_수정할_수_있다() throws Exception { + TaskUpdateRequest request = new TaskUpdateRequest( + "알고리즘 문제 풀기", + "백준1902..", + new TimeFrame( + LocalDateTime.of(2024, 8, 31, 0, 0, 0), + LocalDateTime.of(2024, 8, 31, 23, 59, 59))); + + mockMvc.perform(patch("/v1/tasks/{id}", 1) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(ResultStatus.SUCCESS.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andDo(print()) + .andDo(document("update-a-task/success", + pathParameters( + parameterWithName("id").description("task id") + ), + requestHeaders( + headerWithName(HttpHeaders.ACCEPT).description("accept header"), + headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header") + ), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("description").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("timeFrame").type(JsonFieldType.OBJECT).description("수행기간"), + fieldWithPath("timeFrame.startDateTime").type(JsonFieldType.STRING).description("시작일시 (yyyy-MM-dd)"), + fieldWithPath("timeFrame.endDateTime").type(JsonFieldType.STRING).description("종료일시 (yyyy-MM-dd)") + ), + responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE).description("response body content type") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.STRING).description("성공 여부")))); + } + + @Test + void 수정할_Task_ID가_음수값이면_실패응답을_받는다() throws Exception { + TaskUpdateRequest request = new TaskUpdateRequest( + "알고리즘 문제 풀기", + "백준1902..", + new TimeFrame( + LocalDateTime.of(2024, 8, 31, 0, 0, 0), + LocalDateTime.of(2024, 8, 31, 23, 59, 59))); + + mockMvc.perform(patch("/v1/tasks/{id}", -1) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The id value must be positive.")) + .andDo(document("update-a-task/fail/negative-id-value")); + } + + @Test + void 수정데이터_조건을_만족하지_않는다면_실패응답을_받는다() throws Exception { + String emptyTitle = ""; + String json = "{\n" + + " \"title\":\" " + emptyTitle + " \",\n" + + " \"description\":\"백준1902..\",\n" + + " \"timeFrame\":{\n" + + " \"startDateTime\":\"2024-08-01T00:00:00\",\n" + + " \"endDateTime\":\"2024-08-01T23:59:59\"\n" + + " }\n" + + "}"; + + mockMvc.perform(patch("/v1/tasks/{id}", 1) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(json)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The title of task must not be blank.")) + .andDo(document("update-a-task/fail/invalid-request-data")); + } + + @Test + void 수정할_Task가_존재하지_않는다면_실패응답을_받는다() throws Exception { + TaskUpdateRequest request = new TaskUpdateRequest( + "알고리즘 문제 풀기", + "백준1902..", + new TimeFrame( + LocalDateTime.of(2024, 8, 31, 0, 0, 0), + LocalDateTime.of(2024, 8, 31, 23, 59, 59))); + + mockMvc.perform(patch("/v1/tasks/{id}", 0) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The given task with id does not exist.")) + .andDo(document("update-a-task/fail/does-not-exist")); + } + + @Test + void 사용자는_Task를_삭제할_수_있다() throws Exception { + mockMvc.perform(delete("/v1/tasks/{id}", 1) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(ResultStatus.SUCCESS.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andDo(print()) + .andDo(document("remove-a-task/success", + pathParameters( + parameterWithName("id").description("task id") + ), + requestHeaders( + headerWithName(HttpHeaders.ACCEPT).description("accept header") + ), + responseHeaders( + headerWithName(HttpHeaders.CONTENT_TYPE).description("response body content type") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.STRING).description("성공 여부")))); + } + + @Test + void 삭제할_Task가_존재하지_않는다면_실패응답을_받는다() throws Exception { + mockMvc.perform(delete("/v1/tasks/{id}", 0) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The given task with id does not exist.")) + .andDo(document("remove-a-task/fail/does-not-exist")); + } + + @Test + void 삭제할_Task_ID가_음수값이면_실패응답을_받는다() throws Exception { + mockMvc.perform(delete("/v1/tasks/{id}", -1) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.status").value(ResultStatus.FAIL.name())) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.code").value("INVALID_PARAMETER_STATE")) + .andExpect(jsonPath("$.error.message").value("The id value must be positive.")) + .andDo(document("remove-a-task/fail/negative-id-value")); + } +} diff --git a/api/src/test/java/com/taskbuddy/api/controller/request/TaskCreateRequestTest.java b/api/src/test/java/com/taskbuddy/api/controller/request/TaskCreateRequestTest.java new file mode 100644 index 0000000..6bf8950 --- /dev/null +++ b/api/src/test/java/com/taskbuddy/api/controller/request/TaskCreateRequestTest.java @@ -0,0 +1,65 @@ +package com.taskbuddy.api.controller.request; + +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class TaskCreateRequestTest { + + @ParameterizedTest + @NullAndEmptySource + void Title이_Null이거나_비어있는_값이면_예외를_던진다(String emptyTitle) { + //given + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertThatThrownBy(() -> new TaskCreateRequest(emptyTitle, null, dummyTimeFrame)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("The title of task must not be blank."); + } + + @Test + void description길이가_500자_초과라면_예외를_던진다() { + //given + String longDescription = "A".repeat(501); + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertThatThrownBy(() -> new TaskCreateRequest("sample title", longDescription, dummyTimeFrame)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("The description length must be equal or less than 500"); + } + + @Test + void description길이가_500자_이하라면_정상적으로_객체가_생성된다() { + //given + String longDescription = "A".repeat(500); + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertDoesNotThrow(() -> new TaskCreateRequest("sample title", longDescription, dummyTimeFrame)); + } + + @Test + void timeFrame이_null이라면_예외를_던진다() { + //given & when & then + assertThatThrownBy(() -> new TaskCreateRequest("sample title", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The timeFrame must not be null."); + } + + @Test + void 정상적인_생성자_파라미터로_객체를_생성할_수_있다() { + //given + TimeFrame mockTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertDoesNotThrow(() -> new TaskCreateRequest("sample title", null, mockTimeFrame)); + } +} diff --git a/api/src/test/java/com/taskbuddy/api/controller/request/TaskUpdateRequestTest.java b/api/src/test/java/com/taskbuddy/api/controller/request/TaskUpdateRequestTest.java new file mode 100644 index 0000000..f384b1c --- /dev/null +++ b/api/src/test/java/com/taskbuddy/api/controller/request/TaskUpdateRequestTest.java @@ -0,0 +1,64 @@ +package com.taskbuddy.api.controller.request; + +import com.taskbuddy.api.controller.response.task.TimeFrame; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class TaskUpdateRequestTest { + @ParameterizedTest + @NullAndEmptySource + void Title이_Null이거나_비어있는_값이면_예외를_던진다(String emptyTitle) { + //given + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertThatThrownBy(() -> new TaskUpdateRequest(emptyTitle, null, dummyTimeFrame)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("The title of task must not be blank."); + } + + @Test + void description길이가_500자_초과라면_예외를_던진다() { + //given + String longDescription = "A".repeat(501); + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertThatThrownBy(() -> new TaskUpdateRequest("sample title", longDescription, dummyTimeFrame)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("The description length must be equal or less than 500"); + } + + @Test + void description길이가_500자_이하라면_정상적으로_객체가_생성된다() { + //given + String longDescription = "A".repeat(500); + TimeFrame dummyTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertDoesNotThrow(() -> new TaskUpdateRequest("sample title", longDescription, dummyTimeFrame)); + } + + @Test + void timeFrame이_null이라면_예외를_던진다() { + //given & when & then + assertThatThrownBy(() -> new TaskUpdateRequest("sample title", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The timeFrame must not be null."); + } + + @Test + void 정상적인_생성자_파라미터로_객체를_생성할_수_있다() { + //given + TimeFrame mockTimeFrame = new TimeFrame(LocalDateTime.now(), LocalDateTime.now().plusDays(1)); + + //when & then + assertDoesNotThrow(() -> new TaskUpdateRequest("sample title", null, mockTimeFrame)); + } +} diff --git a/api/src/test/java/com/taskbuddy/api/controller/response/ApiResponseTest.java b/api/src/test/java/com/taskbuddy/api/controller/response/ApiResponseTest.java new file mode 100644 index 0000000..7b360c7 --- /dev/null +++ b/api/src/test/java/com/taskbuddy/api/controller/response/ApiResponseTest.java @@ -0,0 +1,66 @@ +package com.taskbuddy.api.controller.response; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApiResponseTest { + + @Test + void success로_성공상태의_객체를_생성할_수_있다() { + //given + Map body = new HashMap<>(); + body.put("id", "1"); + body.put("name", "Hello"); + + //when + ApiResponse> result = ApiResponse.success(body); + + //then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(ResultStatus.SUCCESS); + assertThat(result.getData()).isEqualTo(body); + assertThat(result.getError()).isNull(); + } + + @Test + void success로_data가_Null이면서_성공상태의_객체를_생성할_수_있다() { + //given + //when + ApiResponse result = ApiResponse.success(null); + + //then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(ResultStatus.SUCCESS); + assertThat(result.getData()).isNull(); + assertThat(result.getError()).isNull(); + } + + @Test + void fail로_실패상태의_객체를_생성할_수_있다() { + //given + ErrorDetail givenErrorDetail = new ErrorDetail("NOT_FOUND_USER", "유저를 찾을 수 없습니다."); + + //when + ApiResponse result = ApiResponse.fail(givenErrorDetail); + + //then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(ResultStatus.FAIL); + assertThat(result.getData()).isNull(); + assertThat(result.getError()).isNotNull(); + assertThat(result.getError()).isEqualTo(givenErrorDetail); + } + + @Test + void error가_null이면_실패상태의_객체를_생성할_수_없다() { + //given & when & then + assertThatThrownBy(() -> ApiResponse.fail(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The error argument must not be null."); + } +} diff --git a/api/src/test/java/com/taskbuddy/api/controller/response/task/TimeFrameTest.java b/api/src/test/java/com/taskbuddy/api/controller/response/task/TimeFrameTest.java new file mode 100644 index 0000000..5627999 --- /dev/null +++ b/api/src/test/java/com/taskbuddy/api/controller/response/task/TimeFrameTest.java @@ -0,0 +1,49 @@ +package com.taskbuddy.api.controller.response.task; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class TimeFrameTest { + + @Test + void startDateTime이_null이면_예외를_던진다() { + //given & when & then + assertThatThrownBy(() -> new TimeFrame(null, LocalDateTime.now())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The startDateTime must not be null."); + } + + @Test + void endDateTime이_null이면_예외를_던진다() { + //given & when & then + assertThatThrownBy(() -> new TimeFrame(LocalDateTime.now(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The endDateTime must not be null."); + } + + @Test + void endDateTime이_startDateTime보다_과거일시라면_예외를_던진다() { + //given + LocalDateTime mockStartDateTime = LocalDateTime.now(); + LocalDateTime mockEndDateTime = mockStartDateTime.minusDays(1); + + //when & then + assertThatThrownBy(() -> new TimeFrame(mockStartDateTime, mockEndDateTime)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("The endDateTime must be after than the startDateTime."); + } + + @Test + void 정상적인_생성자_파라미터로_객체를_생성할_수_있다() { + //given + LocalDateTime mockStartDateTime = LocalDateTime.now(); + LocalDateTime mockEndDateTime = mockStartDateTime.plusHours(1); + + //when & then + assertDoesNotThrow(() -> new TimeFrame(mockStartDateTime, mockEndDateTime)); + } +}