diff --git a/build.gradle b/build.gradle index 41f1127..13e98fd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,35 +1,39 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.2' - id 'io.spring.dependency-management' version '1.1.4' + id 'java' + id 'org.springframework.boot' version '3.2.2' + id 'io.spring.dependency-management' version '1.1.4' } group = 'backend.likelion' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '21' + sourceCompatibility = '21' } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'io.rest-assured:rest-assured' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT + implementation 'com.auth0:java-jwt:4.4.0' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/backend/likelion/todos/TodosApplication.java b/src/main/java/backend/likelion/todos/TodosApplication.java index 26498ac..8e3c230 100644 --- a/src/main/java/backend/likelion/todos/TodosApplication.java +++ b/src/main/java/backend/likelion/todos/TodosApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +@ConfigurationPropertiesScan @SpringBootApplication public class TodosApplication { - public static void main(String[] args) { - SpringApplication.run(TodosApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(TodosApplication.class, args); + } } diff --git a/src/main/java/backend/likelion/todos/auth/Auth.java b/src/main/java/backend/likelion/todos/auth/Auth.java new file mode 100644 index 0000000..aa95be0 --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/Auth.java @@ -0,0 +1,11 @@ +package backend.likelion.todos.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/backend/likelion/todos/auth/AuthArgumentResolver.java b/src/main/java/backend/likelion/todos/auth/AuthArgumentResolver.java new file mode 100644 index 0000000..48eb539 --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/AuthArgumentResolver.java @@ -0,0 +1,45 @@ +package backend.likelion.todos.auth; + +import backend.likelion.todos.auth.jwt.JwtService; +import backend.likelion.todos.common.UnAuthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtService jwtService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean b = parameter.hasParameterAnnotation(Auth.class); + boolean equals = parameter.getParameterType().equals(Long.class); + return b && equals; + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String accessToken = extractAccessToken(webRequest); + return jwtService.extractMemberId(accessToken); + } + + private static String extractAccessToken(NativeWebRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + throw new UnAuthorizedException("로그인 후 접근할 수 있습니다."); + } +} diff --git a/src/main/java/backend/likelion/todos/auth/AuthConfig.java b/src/main/java/backend/likelion/todos/auth/AuthConfig.java new file mode 100644 index 0000000..f7eb982 --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/AuthConfig.java @@ -0,0 +1,19 @@ +package backend.likelion.todos.auth; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class AuthConfig implements WebMvcConfigurer { + + private final AuthArgumentResolver authArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authArgumentResolver); + } +} diff --git a/src/main/java/backend/likelion/todos/auth/AuthTestController.java b/src/main/java/backend/likelion/todos/auth/AuthTestController.java new file mode 100644 index 0000000..fded568 --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/AuthTestController.java @@ -0,0 +1,17 @@ +package backend.likelion.todos.auth; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") +public class AuthTestController { + + @GetMapping("/auth") + public String test( + @Auth Long memberId + ) { + return "아이디가 " + memberId + "인 회원 인증 성공!"; + } +} diff --git a/src/main/java/backend/likelion/todos/auth/jwt/JwtProperty.java b/src/main/java/backend/likelion/todos/auth/jwt/JwtProperty.java new file mode 100644 index 0000000..2e92014 --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/jwt/JwtProperty.java @@ -0,0 +1,10 @@ +package backend.likelion.todos.auth.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("jwt") +public record JwtProperty( + String secretKey, + Long accessTokenExpirationDay +) { +} diff --git a/src/main/java/backend/likelion/todos/auth/jwt/JwtService.java b/src/main/java/backend/likelion/todos/auth/jwt/JwtService.java new file mode 100644 index 0000000..369b60f --- /dev/null +++ b/src/main/java/backend/likelion/todos/auth/jwt/JwtService.java @@ -0,0 +1,46 @@ +package backend.likelion.todos.auth.jwt; + +import static java.util.concurrent.TimeUnit.DAYS; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import backend.likelion.todos.common.UnAuthorizedException; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import java.util.Date; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + private final long accessTokenExpirationDayToMills; + private final Algorithm algorithm; + + public JwtService(JwtProperty jwtProperty) { + this.accessTokenExpirationDayToMills = + MILLISECONDS.convert(jwtProperty.accessTokenExpirationDay(), DAYS); + this.algorithm = Algorithm.HMAC512(jwtProperty.secretKey()); + } + + public String createToken(Long memberId) { + return JWT.create() + .withExpiresAt(new Date( + accessTokenExpirationDayToMills + System.currentTimeMillis() + )) + .withIssuedAt(new Date()) + .withClaim("memberId", memberId) + .sign(algorithm); + } + + public Long extractMemberId(String token) { + try { + return JWT.require(algorithm) + .build() + .verify(token) + .getClaim("memberId") + .asLong(); + } catch (JWTVerificationException e) { + throw new UnAuthorizedException("유효하지 않은 토큰입니다."); + } + } +} diff --git a/src/main/java/backend/likelion/todos/goal/Goal.java b/src/main/java/backend/likelion/todos/goal/Goal.java new file mode 100644 index 0000000..181d831 --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/Goal.java @@ -0,0 +1,35 @@ +package backend.likelion.todos.goal; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.member.Member; +import lombok.Getter; + +@Getter +public class Goal { + + private Long id; + private String name; + private String color; + private Member member; + + public Goal(String name, String color, Member member) { + this.name = name; + this.color = color; + this.member = member; + } + + public void setId(Long id) { + this.id = id; + } + + public void validateMember(Member member) { + if (!this.member.equals(member)) { + throw new ForbiddenException("해당 목표에 대한 권한이 없습니다."); + } + } + + public void update(String name, String color) { + this.name = name; + this.color = color; + } +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalController.java b/src/main/java/backend/likelion/todos/goal/GoalController.java new file mode 100644 index 0000000..d485fa5 --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalController.java @@ -0,0 +1,59 @@ +package backend.likelion.todos.goal; + +import backend.likelion.todos.auth.Auth; +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/goals") +@RestController +public class GoalController { + + private final GoalService goalService; + + @PostMapping + public ResponseEntity create( + @Auth Long memberId, + @RequestBody GoalCreateRequest request + ) { + Long goalId = goalService.save(request.name(), request.color(), memberId); + return ResponseEntity.created(URI.create("/goals/" + goalId)).build(); + } + + @PutMapping("/{id}") + public ResponseEntity update( + @PathVariable("id") Long id, + @Auth Long memberId, + @RequestBody GoalUpdateRequest request + ) { + goalService.update(id, request.name(), request.color(), memberId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable("id") Long id, + @Auth Long memberId + ) { + goalService.delete(id, memberId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/my") + public ResponseEntity> findMine( + @Auth Long memberId + ) { + List result = goalService.findAllByMemberId(memberId); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalCreateRequest.java b/src/main/java/backend/likelion/todos/goal/GoalCreateRequest.java new file mode 100644 index 0000000..d6c9f68 --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalCreateRequest.java @@ -0,0 +1,7 @@ +package backend.likelion.todos.goal; + +public record GoalCreateRequest( + String name, + String color +) { +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalRepository.java b/src/main/java/backend/likelion/todos/goal/GoalRepository.java new file mode 100644 index 0000000..528fa1a --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalRepository.java @@ -0,0 +1,39 @@ +package backend.likelion.todos.goal; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class GoalRepository { + + private final Map goals = new HashMap<>(); + private Long id = 1L; + + public Goal save(Goal goal) { + goal.setId(id); + goals.put(id++, goal); + return goal; + } + + public Optional findById(Long id) { + return Optional.ofNullable(goals.get(id)); + } + + public void clear() { + goals.clear(); + } + + public void delete(Goal goal) { + goals.remove(goal.getId()); + } + + public List findAllByMemberId(Long memberId) { + return goals.values() + .stream() + .filter(it -> it.getMember().getId().equals(memberId)) + .toList(); + } +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalResponse.java b/src/main/java/backend/likelion/todos/goal/GoalResponse.java new file mode 100644 index 0000000..e52f92a --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalResponse.java @@ -0,0 +1,17 @@ +package backend.likelion.todos.goal; + +import lombok.Getter; + +@Getter +public class GoalResponse { + + private final Long id; + private final String name; + private final String color; + + public GoalResponse(Long id, String name, String color) { + this.id = id; + this.name = name; + this.color = color; + } +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalService.java b/src/main/java/backend/likelion/todos/goal/GoalService.java new file mode 100644 index 0000000..751898a --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalService.java @@ -0,0 +1,53 @@ +package backend.likelion.todos.goal; + +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.member.Member; +import backend.likelion.todos.member.MemberRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GoalService { + + private final MemberRepository memberRepository; + private final GoalRepository goalRepository; + + public Long save(String name, String color, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Goal goal = new Goal(name, color, member); + return goalRepository.save(goal) + .getId(); + } + + public void update(Long goalId, String name, String color, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Goal goal = goalRepository.findById(goalId) + .orElseThrow(() -> new NotFoundException("목표 정보가 없습니다.")); + goal.validateMember(member); + goal.update(name, color); + } + + public void delete(Long goalId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Goal goal = goalRepository.findById(goalId) + .orElseThrow(() -> new NotFoundException("목표 정보가 없습니다.")); + goal.validateMember(member); + goalRepository.delete(goal); + } + + public List findAllByMemberId(Long memberId) { + List goals = goalRepository.findAllByMemberId(memberId); + return goals.stream() + .map(it -> new GoalResponse( + it.getId(), + it.getName(), + it.getColor() + )) + .toList(); + } +} diff --git a/src/main/java/backend/likelion/todos/goal/GoalUpdateRequest.java b/src/main/java/backend/likelion/todos/goal/GoalUpdateRequest.java new file mode 100644 index 0000000..131d1d7 --- /dev/null +++ b/src/main/java/backend/likelion/todos/goal/GoalUpdateRequest.java @@ -0,0 +1,7 @@ +package backend.likelion.todos.goal; + +public record GoalUpdateRequest( + String name, + String color +) { +} diff --git a/src/main/java/backend/likelion/todos/member/LoginRequest.java b/src/main/java/backend/likelion/todos/member/LoginRequest.java new file mode 100644 index 0000000..cc07499 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/LoginRequest.java @@ -0,0 +1,7 @@ +package backend.likelion.todos.member; + +public record LoginRequest( + String username, + String password +) { +} diff --git a/src/main/java/backend/likelion/todos/member/LoginResponse.java b/src/main/java/backend/likelion/todos/member/LoginResponse.java new file mode 100644 index 0000000..5f578c3 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/LoginResponse.java @@ -0,0 +1,6 @@ +package backend.likelion.todos.member; + +public record LoginResponse( + String accessToken +) { +} diff --git a/src/main/java/backend/likelion/todos/member/Member.java b/src/main/java/backend/likelion/todos/member/Member.java new file mode 100644 index 0000000..a0f0832 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/Member.java @@ -0,0 +1,32 @@ +package backend.likelion.todos.member; + +import backend.likelion.todos.common.UnAuthorizedException; +import lombok.Getter; + +@Getter +public class Member { + + private Long id; + private String username; + private String password; + private String nickname; + private String profileImageUrl; + + public Member(String username, String password, String nickname, String profileImageUrl) { + this.username = username; + this.password = password; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + + public void setId(Long id) { + this.id = id; + } + + public void login(String password) { + if (this.password.equals(password)) { + return; + } + throw new UnAuthorizedException("비밀번호가 일치하지 않습니다"); + } +} diff --git a/src/main/java/backend/likelion/todos/member/MemberController.java b/src/main/java/backend/likelion/todos/member/MemberController.java new file mode 100644 index 0000000..0a6c867 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/MemberController.java @@ -0,0 +1,51 @@ +package backend.likelion.todos.member; + +import static org.springframework.http.HttpStatus.CREATED; + +import backend.likelion.todos.auth.Auth; +import backend.likelion.todos.auth.jwt.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/members") +public class MemberController { + + private final MemberService memberService; + private final JwtService jwtService; + + @ResponseStatus(CREATED) + @PostMapping + public void signup(@RequestBody SignupRequest request) { + memberService.signup( + request.getUsername(), + request.getPassword(), + request.getNickname(), + request.getProfileImageUrl() + ); + } + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody LoginRequest request + ) { + Long memberId = memberService.login(request.username(), request.password()); + String accessToken = jwtService.createToken(memberId); + return ResponseEntity.ok(new LoginResponse(accessToken)); + } + + @GetMapping("/my") + public ResponseEntity getProfile( + @Auth Long memberId + ) { + MemberResponse response = memberService.findById(memberId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/backend/likelion/todos/member/MemberRepository.java b/src/main/java/backend/likelion/todos/member/MemberRepository.java new file mode 100644 index 0000000..76c1bf4 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/MemberRepository.java @@ -0,0 +1,34 @@ +package backend.likelion.todos.member; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public class MemberRepository { + + private final Map members = new HashMap<>(); + private Long id = 1L; + + public Member save(Member member) { + member.setId(id); + members.put(id++, member); + return member; + } + + public Optional findById(Long id) { + return Optional.ofNullable(members.get(id)); + } + + public Optional findByUsername(String username) { + return members.values() + .stream() + .filter(it -> it.getUsername().equals(username)) + .findAny(); + } + + public void clear() { + members.clear(); + } +} diff --git a/src/main/java/backend/likelion/todos/member/MemberResponse.java b/src/main/java/backend/likelion/todos/member/MemberResponse.java new file mode 100644 index 0000000..79f1662 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/MemberResponse.java @@ -0,0 +1,19 @@ +package backend.likelion.todos.member; + +import lombok.Getter; + +@Getter +public class MemberResponse { + + private final Long memberId; + private final String username; + private final String nickname; + private final String profileImageUrl; + + public MemberResponse(Long memberId, String username, String nickname, String profileImageUrl) { + this.memberId = memberId; + this.username = username; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } +} diff --git a/src/main/java/backend/likelion/todos/member/MemberService.java b/src/main/java/backend/likelion/todos/member/MemberService.java new file mode 100644 index 0000000..4d8e5e3 --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/MemberService.java @@ -0,0 +1,42 @@ +package backend.likelion.todos.member; + +import backend.likelion.todos.common.ConflictException; +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.common.UnAuthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public Long signup(String username, String password, String nickname, String profileImageUrl) { + memberRepository.findByUsername(username) + .ifPresent(it -> { + throw new ConflictException("해당 아이디로 이미 가입한 회원이 있습니다"); + }); + Member member = new Member(username, password, nickname, profileImageUrl); + return memberRepository.save(member) + .getId(); + } + + public Long login(String username, String password) { + Member member = memberRepository.findByUsername(username) + .orElseThrow(() -> new UnAuthorizedException("존재하지 않는 아이디입니다.")); + member.login(password); + return member.getId(); + } + + public MemberResponse findById(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + return new MemberResponse( + member.getId(), + member.getUsername(), + member.getNickname(), + member.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/backend/likelion/todos/member/SignupRequest.java b/src/main/java/backend/likelion/todos/member/SignupRequest.java new file mode 100644 index 0000000..c30f50f --- /dev/null +++ b/src/main/java/backend/likelion/todos/member/SignupRequest.java @@ -0,0 +1,21 @@ +package backend.likelion.todos.member; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SignupRequest { + + private String username; + private String password; + private String nickname; + private String profileImageUrl; + + public SignupRequest(String username, String password, String nickname, String profileImageUrl) { + this.username = username; + this.password = password; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } +} diff --git a/src/main/java/backend/likelion/todos/todo/Todo.java b/src/main/java/backend/likelion/todos/todo/Todo.java new file mode 100644 index 0000000..d1fdf9a --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/Todo.java @@ -0,0 +1,47 @@ +package backend.likelion.todos.todo; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.goal.Goal; +import backend.likelion.todos.member.Member; +import java.time.LocalDate; +import lombok.Getter; + +@Getter +public class Todo { + + private Long id; + private String content; + private LocalDate date; + private Goal goal; + private boolean isCompleted; + + public Todo(String content, LocalDate date, Goal goal) { + this.content = content; + this.date = date; + this.goal = goal; + this.isCompleted = false; + } + + public void setId(Long id) { + this.id = id; + } + + public void validateMember(Member member) { + if (!this.goal.getMember().equals(member)) { + throw new ForbiddenException("해당 투두에 대한 권한이 없습니다."); + } + } + + public void update(String content, LocalDate date) { + this.content = content; + this.date = date; + } + + public void check() { + this.isCompleted = true; + } + + public void uncheck() { + this.isCompleted = false; + } +} diff --git a/src/main/java/backend/likelion/todos/todo/TodoController.java b/src/main/java/backend/likelion/todos/todo/TodoController.java new file mode 100644 index 0000000..0a24c8e --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoController.java @@ -0,0 +1,76 @@ +package backend.likelion.todos.todo; + +import backend.likelion.todos.auth.Auth; +import java.net.URI; +import java.time.YearMonth; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/todos") +@RestController +public class TodoController { + + private final TodoService todoService; + + @PostMapping + public ResponseEntity create( + @Auth Long memberId, + @RequestBody TodoCreateRequest request + ) { + // TODO [9단계] TodoCreateRequest에서 goalId, content, date를 추출하여 todoService의 save 메소드를 호출하고, 생성된 Todo의 ID로 URI를 생성하여 ResponseEntity를 반환하세요. + Long todoId = todoService.save(request.goalId(), memberId, request.content(), request.date()); + return ResponseEntity.created(URI.create("/todos/" + todoId)).build(); + } + + @PostMapping("/{id}/check") + public void check( + @Auth Long memberId, + @PathVariable("id") Long todoId + ) { + // TODO [9단계] todoId와 memberId를 todoService의 check 메소드에 전달하여 Todo를 완료 상태로 변경하세요. + todoService.check(todoId, memberId); + } + + @PostMapping("/{id}/uncheck") + public void uncheck( + @Auth Long memberId, + @PathVariable("id") Long todoId + ) { + // TODO [9단계] todoId와 memberId를 todoService의 uncheck 메소드에 전달하여 Todo를 미완료 상태로 변경하세요. + todoService.uncheck(todoId, memberId); + } + + @PutMapping("/{id}") + public void update( + @Auth Long memberId, + @PathVariable("id") Long todoId, + @RequestBody TodoUpdateRequest request + ) { + // TODO [9단계] TodoUpdateRequest에서 content, date를 추출하고, todoId와 memberId를 함께 todoService의 update 메소드에 전달하여 Todo 정보를 업데이트하세요. + todoService.update(todoId, memberId, request.content(), request.date()); + } + + @DeleteMapping("/{id}") + public void delete( + @Auth Long memberId, + @PathVariable("id") Long todoId + ) { + // TODO [9단계] todoId와 memberId를 todoService의 delete 메소드에 전달하여 Todo를 삭제하세요. + todoService.delete(todoId,memberId); + } + + @GetMapping("/my") + public ResponseEntity> findMine( + @Auth Long memberId, + @RequestParam(value = "year", required = true) int year, + @RequestParam(value = "month", required = true) int month + ) { + // TODO [9단계] memberId와 YearMonth.of(year, month)를 todoService의 findAllByMemberIdAndDate 메소드에 전달하여 해당 기간의 모든 Todo를 조회하고, 조회된 정보를 ResponseEntity.ok()에 담아 반환하세요. + List result = + todoService.findAllByMemberIdAndDate(memberId, YearMonth.of(year, month)); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/backend/likelion/todos/todo/TodoCreateRequest.java b/src/main/java/backend/likelion/todos/todo/TodoCreateRequest.java new file mode 100644 index 0000000..0c96287 --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoCreateRequest.java @@ -0,0 +1,11 @@ +package backend.likelion.todos.todo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; + +public record TodoCreateRequest( + String content, + @JsonFormat(pattern = "yyyy-MM-dd") LocalDate date, + Long goalId +) { +} diff --git a/src/main/java/backend/likelion/todos/todo/TodoRepository.java b/src/main/java/backend/likelion/todos/todo/TodoRepository.java new file mode 100644 index 0000000..838df97 --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoRepository.java @@ -0,0 +1,49 @@ +package backend.likelion.todos.todo; + +import java.time.YearMonth; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Repository; + +@Repository +public class TodoRepository { + + private final Map todos = new HashMap<>(); + private Long id = 1L; + + public Todo save(Todo todo) { + todo.setId(id); + todos.put(id++, todo); + return todo; + } + + public Optional findById(Long id) { + return Optional.ofNullable(todos.get(id)); + } + + public void clear() { + todos.clear(); + } + + public void delete(Todo todo) { + todos.remove(todo.getId()); + } + + public List findAllByMemberIdAndDate(Long memberId, YearMonth yearMonth) { + List list = todos.values() + .stream() + .filter(it -> it.getGoal().getMember().getId().equals(memberId)) + .filter(it -> it.getDate().getYear() == yearMonth.getYear()) + .filter(it -> it.getDate().getMonthValue() == yearMonth.getMonthValue()) + .collect(Collectors.toList()); + Collections.sort(list, Comparator.comparing(Todo::getDate)); + return list; + } +} + + diff --git a/src/main/java/backend/likelion/todos/todo/TodoService.java b/src/main/java/backend/likelion/todos/todo/TodoService.java new file mode 100644 index 0000000..f9713f9 --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoService.java @@ -0,0 +1,93 @@ +package backend.likelion.todos.todo; + + +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.goal.Goal; +import backend.likelion.todos.goal.GoalRepository; +import backend.likelion.todos.member.Member; +import backend.likelion.todos.member.MemberRepository; +import backend.likelion.todos.todo.TodoWithDayResponse.TodoResponse; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TodoService { + + private final MemberRepository memberRepository; + private final GoalRepository goalRepository; + private final TodoRepository todoRepository; + + public Long save(Long goalId, Long memberId, String content, LocalDate date) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Goal goal = goalRepository.findById(goalId) + .orElseThrow(() -> new NotFoundException("목표 정보가 없습니다.")); + goal.validateMember(member); + Todo todo = new Todo(content, date, goal); + return todoRepository.save(todo) + .getId(); + } + + public void update(Long todoId, Long memberId, String content, LocalDate date) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new NotFoundException("투두 정보가 없습니다.")); + todo.validateMember(member); + todo.update(content, date); + } + + public void check(Long todoId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new NotFoundException("투두 정보가 없습니다.")); + todo.validateMember(member); + todo.check(); + } + + public void uncheck(Long todoId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new NotFoundException("투두 정보가 없습니다.")); + todo.validateMember(member); + todo.uncheck(); + } + + public void delete(Long todoId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("회원 정보가 없습니다.")); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new NotFoundException("투두 정보가 없습니다.")); + todo.validateMember(member); + todoRepository.delete(todo); + } + + public List findAllByMemberIdAndDate(Long memberId, YearMonth date) { + List todos = todoRepository.findAllByMemberIdAndDate(memberId, date); + Map> todoWithDays = todos.stream() + .collect(Collectors.groupingBy(it -> it.getDate().getDayOfMonth())); + List responses = new ArrayList<>(); + for (Entry> todo : todoWithDays.entrySet()) { + List todoResponses = todo.getValue().stream() + .map(it -> new TodoResponse( + it.getId(), + it.getContent(), + it.getGoal().getId(), + it.isCompleted() + )) + .toList(); + responses.add(new TodoWithDayResponse(todo.getKey(), todoResponses)); + } + return responses; + } +} diff --git a/src/main/java/backend/likelion/todos/todo/TodoUpdateRequest.java b/src/main/java/backend/likelion/todos/todo/TodoUpdateRequest.java new file mode 100644 index 0000000..ebcda9d --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoUpdateRequest.java @@ -0,0 +1,10 @@ +package backend.likelion.todos.todo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; + +public record TodoUpdateRequest( + String content, + @JsonFormat(pattern = "yyyy-MM-dd") LocalDate date +) { +} diff --git a/src/main/java/backend/likelion/todos/todo/TodoWithDayResponse.java b/src/main/java/backend/likelion/todos/todo/TodoWithDayResponse.java new file mode 100644 index 0000000..cc67cf7 --- /dev/null +++ b/src/main/java/backend/likelion/todos/todo/TodoWithDayResponse.java @@ -0,0 +1,32 @@ +package backend.likelion.todos.todo; + +import java.util.List; +import lombok.Getter; + +@Getter +public class TodoWithDayResponse { + + private final int day; + private final List todos; + + public TodoWithDayResponse(int day, List todos) { + this.day = day; + this.todos = todos; + } + + @Getter + public static class TodoResponse { + + private final Long todoId; + private final String content; + private final Long goalId; + private final boolean isCompleted; + + public TodoResponse(Long todoId, String content, Long goalId, boolean isCompleted) { + this.todoId = todoId; + this.content = content; + this.goalId = goalId; + this.isCompleted = isCompleted; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..1cc4b10 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +jwt: + secretKey: Q05VLUxJS0UtTElPTi1BUFBMSUNBVElPTi1TRUNSRVQtS0VZ #CNU-LIKE-LION-APPLICATION-SECRET-KEY 를 base 64로 인코딩한 값 + accessTokenExpirationDay: 100 # 100일로 설정 diff --git a/src/test/java/backend/likelion/todos/ApiTest.java b/src/test/java/backend/likelion/todos/ApiTest.java index 6f4eef6..5d36dac 100644 --- a/src/test/java/backend/likelion/todos/ApiTest.java +++ b/src/test/java/backend/likelion/todos/ApiTest.java @@ -2,7 +2,9 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import backend.likelion.todos.member.domain.MemberRepository; +import backend.likelion.todos.goal.GoalRepository; +import backend.likelion.todos.member.MemberRepository; +import backend.likelion.todos.todo.TodoRepository; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; @@ -18,9 +20,17 @@ public abstract class ApiTest { @Autowired private MemberRepository memberRepository; + @Autowired + private GoalRepository goalRepository; + + @Autowired + private TodoRepository todoRepository; + @BeforeEach protected void setUp() { RestAssured.port = port; memberRepository.clear(); + goalRepository.clear(); + todoRepository.clear(); } } diff --git a/src/test/java/backend/likelion/todos/auth/AuthAnnotationTest.java b/src/test/java/backend/likelion/todos/auth/AuthAnnotationTest.java new file mode 100644 index 0000000..b287e76 --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/AuthAnnotationTest.java @@ -0,0 +1,43 @@ +package backend.likelion.todos.auth; + +import static java.lang.annotation.ElementType.PARAMETER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Auth 어노테이션은") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class AuthAnnotationTest { + + @Test + void 파라미터에_붙을_수_있다() { + // given + Target target = Auth.class.getDeclaredAnnotation(Target.class); + + // when + ElementType elementType = target.value()[0]; + + // then + assertThat(elementType).isEqualTo(PARAMETER); + } + + @Test + void 런타임_시점까지_어노테이션이_유지된다() { + // given + Retention retention = Auth.class.getDeclaredAnnotation(Retention.class); + + // when + RetentionPolicy policy = retention.value(); + + // then + assertThat(policy).isEqualTo(RetentionPolicy.RUNTIME); + } +} diff --git a/src/test/java/backend/likelion/todos/auth/AuthArgumentResolverTest.java b/src/test/java/backend/likelion/todos/auth/AuthArgumentResolverTest.java new file mode 100644 index 0000000..59dcfbc --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/AuthArgumentResolverTest.java @@ -0,0 +1,157 @@ +package backend.likelion.todos.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import backend.likelion.todos.auth.jwt.JwtService; +import backend.likelion.todos.common.UnAuthorizedException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@DisplayName("AuthArgumentResolver 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class AuthArgumentResolverTest { + + @Test + void HandlerMethodArgumentResolver_를_구현한다() { + // when + Class[] interfaces = AuthArgumentResolver.class.getInterfaces(); + + // then + assertThat(interfaces).containsOnly(HandlerMethodArgumentResolver.class); + } + + @Nested + class 파라미터_지원여부_확인_시 { + + @Test + void 파라미터가_Long타입이며_Auth_어노테이션이_붙어있는_경우에만_지원한다() { + // given + AuthArgumentResolver resolver = new AuthArgumentResolver(mock(JwtService.class)); + MethodParameter methodParameter = mock(MethodParameter.class); + given(methodParameter.hasParameterAnnotation(Auth.class)) + .willReturn(true); + given(methodParameter.getParameterType()) + .willReturn((Class) Long.class); + + // when + boolean isSupport = resolver.supportsParameter(methodParameter); + + // then + assertThat(isSupport).isTrue(); + } + + @Test + void 파라미터가_Long타입이_아니면_지원하지_않는다() { + // given + AuthArgumentResolver resolver = new AuthArgumentResolver(mock(JwtService.class)); + MethodParameter methodParameter = mock(MethodParameter.class); + given(methodParameter.hasParameterAnnotation(Auth.class)) + .willReturn(true); + given(methodParameter.getParameterType()) + .willReturn((Class) String.class); + + // when + boolean isSupport = resolver.supportsParameter(methodParameter); + + // then + assertThat(isSupport).isFalse(); + } + + @Test + void 파라미터에_Auth_어노테이션이_없으면_지원하지_않는다() { + // given + AuthArgumentResolver resolver = new AuthArgumentResolver(mock(JwtService.class)); + MethodParameter methodParameter = mock(MethodParameter.class); + given(methodParameter.hasParameterAnnotation(Auth.class)) + .willReturn(false); + given(methodParameter.getParameterType()) + .willReturn((Class) Long.class); + + // when + boolean isSupport = resolver.supportsParameter(methodParameter); + + // then + assertThat(isSupport).isFalse(); + } + } + + @Nested + class 파라미터_resolve_시 { + + private final JwtService jwtService = mock(JwtService.class); + private final AuthArgumentResolver resolver = new AuthArgumentResolver(jwtService); + private final MethodParameter parameter = mock(MethodParameter.class); + private final ModelAndViewContainer mavContainer = mock(ModelAndViewContainer.class); + private final NativeWebRequest webRequest = mock(NativeWebRequest.class); + private final WebDataBinderFactory binderFactor = mock(WebDataBinderFactory.class); + + @Test + void request_의_Authorization_헤더가_없으면_예외() { + // given + given(webRequest.getHeader("Authorization")) + .willReturn(null); + + // when & then + assertThatThrownBy(() -> { + resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactor); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("로그인 후 접근할 수 있습니다."); + } + + @Test + void request_의_Authorization_헤더의_값이_Bearer_토큰_형태가_아니면_예외() { + // given + given(webRequest.getHeader("Authorization")) + .willReturn("jwt"); + + // when & then + assertThatThrownBy(() -> { + resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactor); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("로그인 후 접근할 수 있습니다."); + } + + @Test + void request_의_Authorization_헤더의_Bearer_토큰이_유효하지_않으면_예외() { + // given + given(webRequest.getHeader("Authorization")) + .willReturn("Bearer invalid"); + given(jwtService.extractMemberId("invalid")) + .willThrow(new UnAuthorizedException("유효하지 않은 토큰입니다.")); + + // when & then + assertThatThrownBy(() -> { + resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactor); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("유효하지 않은 토큰입니다."); + } + + @Test + void request_의_Authorization_헤더의_Bearer_토큰이_유효하다면_해당_토큰에서_회원_id를_꺼내어_세팅한다() { + // given + given(webRequest.getHeader("Authorization")) + .willReturn("Bearer valid"); + given(jwtService.extractMemberId("valid")) + .willReturn(1L); + + // when + Object result = resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactor); + + // then + assertThat(result).isEqualTo(1L); + } + } +} + diff --git a/src/test/java/backend/likelion/todos/auth/AuthConfigTest.java b/src/test/java/backend/likelion/todos/auth/AuthConfigTest.java new file mode 100644 index 0000000..ab16173 --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/AuthConfigTest.java @@ -0,0 +1,44 @@ +package backend.likelion.todos.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@DisplayName("AuthConfig 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class AuthConfigTest { + + @Test + void WebMvcConfigurer_를_구현한다() { + // when + Class anInterface = AuthConfig.class.getInterfaces()[0]; + + // then + assertThat(anInterface).isEqualTo(WebMvcConfigurer.class); + } + + @Test + void addArgumentResolvers에_AuthArgumentResolver_를_등록한다() { + // given + AuthArgumentResolver resolver = mock(AuthArgumentResolver.class); + AuthConfig authConfig = new AuthConfig(resolver); + List resolvers = new ArrayList<>(); + + // when + authConfig.addArgumentResolvers(resolvers); + + // then + assertThat(resolvers) + .hasSize(1) + .containsExactly(resolver); + } +} diff --git a/src/test/java/backend/likelion/todos/auth/AuthTestControllerTest.java b/src/test/java/backend/likelion/todos/auth/AuthTestControllerTest.java new file mode 100644 index 0000000..6c58ebb --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/AuthTestControllerTest.java @@ -0,0 +1,54 @@ +package backend.likelion.todos.auth; + + +import static org.assertj.core.api.Assertions.assertThat; + +import backend.likelion.todos.ApiTest; +import backend.likelion.todos.auth.jwt.JwtService; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +@DisplayName("인증이 필요한 요청 테스트") +class AuthTestControllerTest extends ApiTest { + + @Autowired + private JwtService jwtService; + + @Test + void 인증이_필요한_요청에_대해서는_accessToken을_Request_Header의_Authorization_값으로_설정하여_보낸다_이때_값은_Bearer_accessToken_형식이다() { + // given + String accessToken = jwtService.createToken(10L); + + // when + ExtractableResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .get("/test/auth") + .then() + .extract(); + + // then + assertThat(response.asString()).isEqualTo("아이디가 10인 회원 인증 성공!"); + } + + @Test + void accessToken이_올바르지_않은_경우_접근할_수_없다() { + // when + ExtractableResponse response = RestAssured.given() + .header("Authorization", "Bearer " + "wrong") + .get("/test/auth") + .then() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + } +} diff --git a/src/test/java/backend/likelion/todos/auth/jwt/JwtPropertyTest.java b/src/test/java/backend/likelion/todos/auth/jwt/JwtPropertyTest.java new file mode 100644 index 0000000..13f6870 --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/jwt/JwtPropertyTest.java @@ -0,0 +1,48 @@ +package backend.likelion.todos.auth.jwt; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Base64; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@DisplayName("Jwt 설정 (JwtProperty) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class JwtPropertyTest { + + @Autowired + private JwtProperty jwtProperty; + + + @Test + void application_yml_파일_내의_jwt_하위의_속성과_매핑된다() { + // when + ConfigurationProperties configurationProperties = JwtProperty.class + .getDeclaredAnnotation(ConfigurationProperties.class); + + // then + assertThat(configurationProperties.value()).isEqualTo("jwt"); + } + + @Test + @DisplayName("해당 속성의 시크릿 키는 CNU-LIKE-LION-APPLICATION-SECRET-KEY 를 base 64로 인코딩한 값이다.") + void secretKeyTest() { + // given + String origin = "CNU-LIKE-LION-APPLICATION-SECRET-KEY"; + String expected = Base64.getEncoder().encodeToString(origin.getBytes(UTF_8)); + + // when + String actual = jwtProperty.secretKey(); + + // then + assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/backend/likelion/todos/auth/jwt/JwtServiceTest.java b/src/test/java/backend/likelion/todos/auth/jwt/JwtServiceTest.java new file mode 100644 index 0000000..6300d96 --- /dev/null +++ b/src/test/java/backend/likelion/todos/auth/jwt/JwtServiceTest.java @@ -0,0 +1,52 @@ +package backend.likelion.todos.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import backend.likelion.todos.common.UnAuthorizedException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@DisplayName("JWT 서비스 (JwtService) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class JwtServiceTest { + + @Autowired + private JwtService jwtService; + + @Test + void 회원_id를_받아_JWT를_생성한다() { + // when + String token = jwtService.createToken(1L); + + // then + assertThat(token).isNotNull(); + } + + @Test + void 주어진_JWT내의_회원_ID_정보를_추출한다() { + // given + String token = jwtService.createToken(1L); + + // when + Long id = jwtService.extractMemberId(token); + + // then + assertThat(id).isEqualTo(1L); + } + + @Test + void 토큰_정보_추출_시_토큰이_만료되었거나_잘못되었다면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> { + jwtService.extractMemberId("invalidToken"); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("유효하지 않은 토큰입니다."); + } +} diff --git a/src/test/java/backend/likelion/todos/goal/GoalAcceptanceTest.java b/src/test/java/backend/likelion/todos/goal/GoalAcceptanceTest.java new file mode 100644 index 0000000..8e661cc --- /dev/null +++ b/src/test/java/backend/likelion/todos/goal/GoalAcceptanceTest.java @@ -0,0 +1,306 @@ +package backend.likelion.todos.goal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import backend.likelion.todos.ApiTest; +import backend.likelion.todos.common.ExceptionResponse; +import backend.likelion.todos.member.LoginRequest; +import backend.likelion.todos.member.LoginResponse; +import backend.likelion.todos.member.SignupRequest; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("목표 인수테스트") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class GoalAcceptanceTest extends ApiTest { + + private String member1Token; + private String member2Token; + + @BeforeEach + @Override + protected void setUp() { + super.setUp(); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(new SignupRequest( + "likelion", + "likelion1234", + "멋사", + "profile" + )) + .post("/members") + .then() + .log().all() + .extract(); + ExtractableResponse response1 = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(new LoginRequest( + "likelion", + "likelion1234" + )) + .post("/members/login") + .then() + .log().all() + .extract(); + member1Token = response1.as(LoginResponse.class).accessToken(); + + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(new SignupRequest( + "unlikelion", + "likelion1234", + "안멋사", + "profile" + )) + .post("/members") + .then() + .log().all() + .extract(); + ExtractableResponse response2 = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(new LoginRequest( + "unlikelion", + "likelion1234" + )) + .post("/members/login") + .then() + .log().all() + .extract(); + member2Token = response2.as(LoginResponse.class).accessToken(); + } + + @Nested + class 목표_생성_시 { + + @Test + void 인증정보가_없으면_401_예외() { + // given + GoalCreateRequest request = new GoalCreateRequest("목표", "#000000"); + + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(request) + .post("/goals") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void 목표를_생성하면_201_상태코드와_함께_응답의_Location_헤더에_목표_Id를_담아준다() { + // given + GoalCreateRequest request = new GoalCreateRequest("목표", "#000000"); + + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(request) + .post("/goals") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(201); + String goalId = response.header("Location").replace("/goals/", ""); + assertThat(goalId).isNotNull(); + } + } + + @Nested + class 목표_수정_시 { + + private Long goalId; + + @BeforeEach + void setUp() { + GoalCreateRequest request = new GoalCreateRequest("목표", "#000000"); + goalId = Long.valueOf(RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(request) + .post("/goals") + .then() + .log().all() + .extract() + .header("Location") + .replace("/goals/", "")); + } + + @Test + void 내_목표가_아니면_403_예외() { + // given + GoalUpdateRequest request = new GoalUpdateRequest("update", "#111111"); + + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member2Token) + .body(request) + .put("/goals/{id}", goalId) + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(403); + assertThat(response.as(ExceptionResponse.class).message()) + .isEqualTo("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 목표를_수정한다() { + // given + GoalUpdateRequest request = new GoalUpdateRequest("update", "#111111"); + + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(request) + .put("/goals/{id}", goalId) + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(200); + } + } + + @Nested + class 목표_삭제_시 { + + private Long goalId; + + @BeforeEach + void setUp() { + GoalCreateRequest request = new GoalCreateRequest("목표", "#000000"); + goalId = Long.valueOf(RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(request) + .post("/goals") + .then() + .log().all() + .extract() + .header("Location") + .replace("/goals/", "")); + } + + @Test + void 내_목표가_아니면_403_예외() { + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member2Token) + .delete("/goals/{id}", goalId) + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(403); + assertThat(response.as(ExceptionResponse.class).message()) + .isEqualTo("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 목표를_삭제한다() { + // when + ExtractableResponse response = RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .delete("/goals/{id}", goalId) + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(200); + } + } + + @Nested + class 내_목표_조회_시 { + + + @BeforeEach + void setUp() { + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(new GoalCreateRequest("회원1 목표1", "#000000")) + .post("/goals") + .then() + .log().all(); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .body(new GoalCreateRequest("회원1 목표2", "#000000")) + .post("/goals") + .then() + .log().all(); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .header(AUTHORIZATION, "Bearer " + member2Token) + .body(new GoalCreateRequest("회원2 목표1", "#000000")) + .post("/goals") + .then() + .log().all(); + } + + @Test + void 내_목표를_조회한다() { + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .header(AUTHORIZATION, "Bearer " + member1Token) + .get("/goals/my") + .then() + .log().all() + .extract(); + + // then + List responses = response.as(new TypeRef<>() { + }); + assertThat(responses) + .hasSize(2) + .extracting(GoalResponse::getName) + .containsExactlyInAnyOrder("회원1 목표1", "회원1 목표2"); + } + } +} diff --git a/src/test/java/backend/likelion/todos/goal/GoalControllerTest.java b/src/test/java/backend/likelion/todos/goal/GoalControllerTest.java new file mode 100644 index 0000000..bd9791b --- /dev/null +++ b/src/test/java/backend/likelion/todos/goal/GoalControllerTest.java @@ -0,0 +1,83 @@ +package backend.likelion.todos.goal; + +import static org.assertj.core.api.Assertions.assertThat; + +import backend.likelion.todos.auth.Auth; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@DisplayName("목표 컨트롤러 (GoalController) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class GoalControllerTest { + + @Test + void RestController_빈으로_등록한다() { + // when + boolean isRestController = GoalController.class.isAnnotationPresent(RestController.class); + + // then + assertThat(isRestController).isTrue(); + } + + @Test + void goals_로_들어오는_요청을_처리한다() { + // given + RequestMapping requestMapping = GoalController.class.getAnnotation(RequestMapping.class); + + // when + String uri = requestMapping.value()[0]; + + // then + assertThat(uri).isEqualTo("/goals"); + } + + @Test + @DisplayName("생성 시 회원 정보가 필요하므로 @Auth 를 통해 회원 Id를 받아와야 한다.") + void 생성_메서드_테스트() throws NoSuchMethodException { + // given + Method create = Arrays.stream(GoalController.class.getDeclaredMethods()) + .filter(it -> it.getName().equals("create")) + .findAny().get(); + + // when + Optional authParam = Arrays.stream(create.getParameters()) + .filter(it -> it.isAnnotationPresent(Auth.class)) + .findAny(); + + // then + assertThat(authParam).isPresent(); + } + + @Test + @DisplayName("수정 메서드는 PUT /goals/{id} 형태이며, id는 PathVariable 로 받아온다") + void 수정_메서드_테스트() { + // given + Method update = Arrays.stream(GoalController.class.getDeclaredMethods()) + .filter(it -> it.getName().equals("update")) + .findAny().get(); + + // when + PutMapping putMapping = update.getDeclaredAnnotation(PutMapping.class); + Optional pathVariableParam = Arrays.stream(update.getParameters()) + .filter(it -> it.isAnnotationPresent(PathVariable.class)) + .findAny(); + + // then + assertThat(putMapping.value()).containsOnly("/{id}"); + assertThat(pathVariableParam).isPresent(); + Parameter parameter = pathVariableParam.get(); + PathVariable pathVariable = parameter.getDeclaredAnnotation(PathVariable.class); + assertThat(pathVariable.value()).isEqualTo("id"); + } +} diff --git a/src/test/java/backend/likelion/todos/goal/GoalRepositoryTest.java b/src/test/java/backend/likelion/todos/goal/GoalRepositoryTest.java new file mode 100644 index 0000000..b4a27ba --- /dev/null +++ b/src/test/java/backend/likelion/todos/goal/GoalRepositoryTest.java @@ -0,0 +1,59 @@ +package backend.likelion.todos.goal; + +import static org.assertj.core.api.Assertions.assertThat; + +import backend.likelion.todos.member.Member; +import backend.likelion.todos.member.MemberRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("목표 저장소 (GoalRepository) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class GoalRepositoryTest { + + private final GoalRepository goalRepository = new GoalRepository(); + private final MemberRepository memberRepository = new MemberRepository(); + private final Member member1 = new Member("u", "p", "n", "p"); + private final Member member2 = new Member("u", "p", "n", "p"); + + @BeforeEach + void setUp() { + memberRepository.save(member1); + memberRepository.save(member2); + } + + @Test + void 목표를_제거한다() { + // given + Goal goal = goalRepository.save(new Goal("n", "c", member1)); + + // when + goalRepository.delete(goal); + + // then + assertThat(goalRepository.findById(goal.getId())).isEmpty(); + } + + @Test + void 특정_회원의_목표를_모두_조회한다() { + // given + Goal goal1 = goalRepository.save(new Goal("n1", "c", member1)); + Goal goal2 = goalRepository.save(new Goal("n2", "c", member2)); + Goal goal3 = goalRepository.save(new Goal("n3", "c", member1)); + Goal goal4 = goalRepository.save(new Goal("n4", "c", member2)); + + // when + List goals = goalRepository.findAllByMemberId(member1.getId()); + + // then + assertThat(goals) + .hasSize(2) + .extracting(Goal::getName) + .containsExactlyInAnyOrder("n1", "n3"); + } +} diff --git a/src/test/java/backend/likelion/todos/goal/GoalServiceTest.java b/src/test/java/backend/likelion/todos/goal/GoalServiceTest.java new file mode 100644 index 0000000..8634b6a --- /dev/null +++ b/src/test/java/backend/likelion/todos/goal/GoalServiceTest.java @@ -0,0 +1,195 @@ +package backend.likelion.todos.goal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.member.Member; +import backend.likelion.todos.member.MemberRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@DisplayName("목표 서비스 (GoalService) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class GoalServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private GoalService goalService; + + private Member member; + private Member other; + + @BeforeEach + void setUp() { + goalRepository.clear(); + memberRepository.clear(); + member = memberRepository.save(new Member("1", "1", "1", "1")); + other = memberRepository.save(new Member("2", "2", "2", "2")); + } + + @Nested + class 목표_생성_시 { + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + goalService.save("목표", "#111111", member.getId() + 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 목표를_생성한다() { + // when + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // then + assertThat(goalId).isNotNull(); + assertThat(goalRepository.findById(goalId)).isPresent(); + } + } + + @Nested + class 목표_수정_시 { + + @Test + void 회원_정보가_없으면_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.update(goalId, "u", "c", member.getId() + 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 목표가_없으면_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.update(goalId + 100L, "u", "c", member.getId()); + }).isInstanceOf(NotFoundException.class) + .hasMessage("목표 정보가 없습니다."); + } + + @Test + void 목표를_생성한_회원이_아닌_다른_회원이_수정하려는_경우_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.update(goalId, "u", "c", other.getId()); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 수정_성공() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when + goalService.update(goalId, "u", "c", member.getId()); + + // then + Goal goal = goalRepository.findById(goalId).get(); + assertThat(goal.getName()).isEqualTo("u"); + assertThat(goal.getColor()).isEqualTo("c"); + } + } + + @Nested + class 목표_삭제_시 { + + @Test + void 회원_정보가_없으면_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.delete(goalId, member.getId() + 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 목표가_없으면_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.delete(goalId + 100L, member.getId()); + }).isInstanceOf(NotFoundException.class) + .hasMessage("목표 정보가 없습니다."); + } + + @Test + void 목표를_생성한_회원이_아닌_다른_회원이_삭제하려는_경우_예외() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when & then + assertThatThrownBy(() -> { + goalService.delete(goalId, other.getId()); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 삭제_성공() { + // given + Long goalId = goalService.save("목표", "#111111", member.getId()); + + // when + goalService.delete(goalId, member.getId()); + + // then + assertThat(goalRepository.findById(goalId)).isEmpty(); + } + } + + @Nested + class 특정_회원의_목표_전체_조회_시 { + + @Test + void 성공() { + // given + Long goalId1 = goalService.save("목표1", "#111111", member.getId()); + Long goalId2 = goalService.save("목표2", "#111111", other.getId()); + Long goalId3 = goalService.save("목표3", "#111111", member.getId()); + + // when + List response = goalService.findAllByMemberId(goalId1); + + // then + assertThat(response) + .hasSize(2) + .extracting(GoalResponse::getName) + .containsExactlyInAnyOrder("목표1", "목표3"); + } + } +} diff --git a/src/test/java/backend/likelion/todos/goal/GoalTest.java b/src/test/java/backend/likelion/todos/goal/GoalTest.java new file mode 100644 index 0000000..feae383 --- /dev/null +++ b/src/test/java/backend/likelion/todos/goal/GoalTest.java @@ -0,0 +1,62 @@ +package backend.likelion.todos.goal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("목표 (Goal) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class GoalTest { + + private final Member member = new Member("u", "p", "n", "p"); + private final Member other = new Member("o", "p", "n", "p"); + + @Nested + class 회원_검증_시 { + + @Test + void 주인이_아니면_예외() { + // given + Goal goal = new Goal("name", "color", member); + + // when & then + assertThatThrownBy(() -> { + goal.validateMember(other); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 주인이라면_통과() { + // given + Goal goal = new Goal("name", "color", member); + + // when & then + assertDoesNotThrow(() -> { + goal.validateMember(member); + }); + } + } + + @Test + void 이름과_색깔을_수정할_수_있다() { + // given + Goal goal = new Goal("name", "color", member); + + // when + goal.update("uname", "ucolor"); + + // then + assertThat(goal.getName()).isEqualTo("uname"); + assertThat(goal.getColor()).isEqualTo("ucolor"); + } +} diff --git a/src/test/java/backend/likelion/todos/member/LoginRequestTest.java b/src/test/java/backend/likelion/todos/member/LoginRequestTest.java new file mode 100644 index 0000000..efb76c1 --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/LoginRequestTest.java @@ -0,0 +1,20 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("로그인 요청 (LoginRequest) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class LoginRequestTest { + + @Test + void record_클래스도_사용_가능하다() { + // when & then + assertThat(LoginRequest.class.isRecord()).isTrue(); + } +} diff --git a/src/test/java/backend/likelion/todos/member/LoginResponseTest.java b/src/test/java/backend/likelion/todos/member/LoginResponseTest.java new file mode 100644 index 0000000..b68b1e8 --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/LoginResponseTest.java @@ -0,0 +1,20 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("로그인 응답 (LoginResponse) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class LoginResponseTest { + + @Test + void 응답_클래스도_record_타입이_가능하다() { + // when & then + assertThat(LoginResponse.class.isRecord()).isTrue(); + } +} diff --git a/src/test/java/backend/likelion/todos/member/MemberControllerTest.java b/src/test/java/backend/likelion/todos/member/MemberControllerTest.java new file mode 100644 index 0000000..494fec3 --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/MemberControllerTest.java @@ -0,0 +1,270 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.CREATED; + +import backend.likelion.todos.ApiTest; +import backend.likelion.todos.common.ExceptionResponse; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@DisplayName("회원 컨트롤러 (MemberController) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class MemberControllerTest extends ApiTest { + + @Test + void RestController_빈으로_등록한다() { + // when + boolean isRestController = MemberController.class.isAnnotationPresent(RestController.class); + + // then + assertThat(isRestController).isTrue(); + } + + @Test + void members_로_들어오는_요청을_처리한다() { + // given + RequestMapping requestMapping = MemberController.class.getAnnotation(RequestMapping.class); + + // when + String uri = requestMapping.value()[0]; + + // then + assertThat(uri).isEqualTo("/members"); + } + + @Nested + class 회원가입_시 { + + @Test + void 성공하면_201_상태코드와_함께_응답_헤더의_Location_값으로_생성된_회원을_조회할_수_있는_URL이_반환된다() { + // given + SignupRequest request = new SignupRequest( + "likelion", + "likelion1234", + "멋사", + "profileurl" + ); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .post("/members") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(CREATED.value()); + } + + @Test + void 아이디가_중복되면_409_상태코드와_예외_메세지를_반환한다() { + // given + SignupRequest request = new SignupRequest( + "likelion", + "likelion1234", + "멋사", + "profileurl" + ); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(request) + .post("/members") + .then() + .log().all() + .extract(); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .post("/members") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(CONFLICT.value()); + } + } + + @Nested + class 로그인_시 { + + @BeforeEach + void setUp() { + SignupRequest request = new SignupRequest( + "likelion", + "likelion1234", + "멋사", + "profile" + ); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(request) + .post("/members") + .then() + .log().all() + .extract(); + } + + @Test + void 아이디가_없다면_401_상태코드와_예외_메세지를_보낸다() { + // given + LoginRequest request = new LoginRequest( + "none", + "likelion1234" + ); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .post("/members/login") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.as(ExceptionResponse.class).message()) + .isEqualTo("존재하지 않는 아이디입니다."); + } + + @Test + void 비밀번호가_틀렸다면_401_상태코드와_예외_메세지를_보낸다() { + // given + LoginRequest request = new LoginRequest( + "likelion", + "wrong" + ); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .post("/members/login") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.as(ExceptionResponse.class).message()) + .isEqualTo("비밀번호가 일치하지 않습니다"); + } + + @Test + void 로그인에_성공하면_200_상태코드와_AccessToken을_반환한다() { + // given + LoginRequest request = new LoginRequest( + "likelion", + "likelion1234" + ); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .post("/members/login") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.as(LoginResponse.class).accessToken()) + .isNotNull(); + } + } + + @Nested + class 내_정보_조회_시 { + + private String accessToken; + + @BeforeEach + void setUp() { + SignupRequest request = new SignupRequest( + "likelion", + "likelion1234", + "멋사", + "profile" + ); + RestAssured.given() + .contentType(ContentType.JSON) + .log().all() + .body(request) + .post("/members") + .then() + .log().all() + .extract(); + ExtractableResponse response = RestAssured.given() + .log().all() + .contentType(ContentType.JSON) + .body(new LoginRequest( + "likelion", + "likelion1234" + )) + .post("/members/login") + .then() + .log().all() + .extract(); + accessToken = response.as(LoginResponse.class).accessToken(); + } + + @Test + void 인증되지_않았다면_401_오류() { + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .get("/members/my") + .then() + .log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void 인증정보가_있다면_내_정보를_반환한다() { + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .header("Authorization", "Bearer " + accessToken) + .get("/members/my") + .then() + .log().all() + .extract(); + + // then + MemberResponse memberResponse = response.as(MemberResponse.class); + assertThat(memberResponse.getUsername()).isEqualTo("likelion"); + assertThat(memberResponse.getNickname()).isEqualTo("멋사"); + assertThat(memberResponse.getProfileImageUrl()).isEqualTo("profile"); + } + } +} diff --git a/src/test/java/backend/likelion/todos/member/MemberRepositoryTest.java b/src/test/java/backend/likelion/todos/member/MemberRepositoryTest.java new file mode 100644 index 0000000..252bae9 --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/MemberRepositoryTest.java @@ -0,0 +1,107 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.stereotype.Repository; + +@DisplayName("회원 저장소 (MemberRepository) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class MemberRepositoryTest { + + private final MemberRepository memberRepository = new MemberRepository(); + + @Test + void Repository_빈으로_등록한다() { + // when + boolean isRepository = MemberRepository.class.isAnnotationPresent(Repository.class); + + // then + assertThat(isRepository).isTrue(); + } + + @Test + void 회원_객체에_Id를_세팅하고_저장한다() { + // given + Member member = new Member("u", "p", "n", "p"); + + // when + Member saved = memberRepository.save(member); + + // then + assertThat(saved.getId()).isEqualTo(1L); + } + + @Nested + class id로_회원_조회_시 { + + @Test + void 존재하면_회원_객체가_Optional로_래핑되어_반환된다() { + // given + Member member = new Member("u", "p", "n", "p"); + Member saved = memberRepository.save(member); + + // when + Optional found = memberRepository.findById(saved.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getUsername()).isEqualTo("u"); + } + + @Test + void 존재하지_않으면_Optional_empty가_반환된다() { + // when + Optional found = memberRepository.findById(100L); + + // then + assertThat(found).isEmpty(); + } + } + + @Nested + class username_으로_회원_조회_시 { + + @Test + void 존재하면_회원_객체가_Optional로_래핑되어_반환된다() { + // given + Member member = new Member("u", "p", "n", "p"); + Member saved = memberRepository.save(member); + + // when + Optional found = memberRepository.findByUsername("u"); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getUsername()).isEqualTo("u"); + } + + @Test + void 존재하지_않으면_Optional_empty가_반환된다() { + // when + Optional found = memberRepository.findByUsername("u"); + + // then + assertThat(found).isEmpty(); + } + } + + @Test + void clear_시_데이터를_다_지운다() { + // given + Member member = new Member("u", "p", "n", "p"); + memberRepository.save(member); + + // when + memberRepository.clear(); + + // then + assertThat(memberRepository.findById(member.getId())).isEmpty(); + } +} diff --git a/src/test/java/backend/likelion/todos/member/MemberServiceTest.java b/src/test/java/backend/likelion/todos/member/MemberServiceTest.java new file mode 100644 index 0000000..99b9e62 --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/MemberServiceTest.java @@ -0,0 +1,154 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import backend.likelion.todos.common.ConflictException; +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.common.UnAuthorizedException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.stereotype.Service; + +@SpringBootTest +@DisplayName("회원 서비스 (MemberService) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class MemberServiceTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + memberRepository.clear(); + } + + @Test + void Service_빈으로_등록한다() { + // when + boolean isService = MemberService.class.isAnnotationPresent(Service.class); + + // then + assertThat(isService).isTrue(); + } + + @Test + void MemberRepository_를_받는_생성자_단_하나만이_존재한다() { + // when + Constructor[] declaredConstructors = MemberService.class.getDeclaredConstructors(); + + // then + assertThat(declaredConstructors).hasSize(1); + Constructor ctor = declaredConstructors[0]; + assertThat(ctor.getParameters()) + .hasSize(1) + .extracting(Parameter::getType) + .containsOnly((Class) MemberRepository.class); + } + + @Nested + class 회원가입_시 { + + @Test + void 아이디가_중복되면_예외() { + // given + memberService.signup("u", "p", "n", "p"); + + // when & then + assertThatThrownBy(() -> { + memberService.signup("u", "p", "n", "p"); + }).isInstanceOf(ConflictException.class) + .hasMessage("해당 아이디로 이미 가입한 회원이 있습니다"); + } + + @Test + void 중복되는_아이디가_없으면_회원가입에_성공한다() { + // when + Long memberId = memberService.signup("u", "p", "n", "p"); + + // then + assertThat(memberId).isNotNull(); + } + } + + @Nested + class 로그인_시 { + + @Test + void 아이디가_없으면_로그인_실패() { + // when & then + assertThatThrownBy(() -> { + memberService.login("u", "p"); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("존재하지 않는 아이디입니다."); + } + + @Test + void 비밀번호가_다르면_로그인_실패() { + // given + memberService.signup("u", "p", "n", "p"); + + // when & then + assertThatThrownBy(() -> { + memberService.login("u", "1"); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + void 아이디와_비밀번호가_올바르면_로그인_성공() { + // given + Long memberId = memberService.signup("u", "p", "n", "p"); + + // when + Long loginId = memberService.login("u", "p"); + + // then + assertThat(memberId).isEqualTo(loginId); + } + } + + @Nested + class 회원_정보_조회_시 { + + private Long memberId; + + @BeforeEach + void setUp() { + memberId = memberService.signup("u", "p", "n", "p"); + } + + @Test + void Id_로_회원_정보를_조회한다() { + // when + MemberResponse response = memberService.findById(memberId); + + // then + assertThat(response.getMemberId()).isEqualTo(memberId); + assertThat(response.getUsername()).isEqualTo("u"); + assertThat(response.getNickname()).isEqualTo("n"); + assertThat(response.getProfileImageUrl()).isEqualTo("p"); + } + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + memberService.findById(100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + } +} diff --git a/src/test/java/backend/likelion/todos/member/MemberTest.java b/src/test/java/backend/likelion/todos/member/MemberTest.java new file mode 100644 index 0000000..e0b989d --- /dev/null +++ b/src/test/java/backend/likelion/todos/member/MemberTest.java @@ -0,0 +1,39 @@ +package backend.likelion.todos.member; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import backend.likelion.todos.common.UnAuthorizedException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("회원 (Member) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class MemberTest { + + @Test + void 로그인_시_비밀번호가_다르면_예외() { + // given + Member member = new Member("u", "p", "n", "p"); + + // when & then + assertThatThrownBy(() -> { + member.login("1"); + }).isInstanceOf(UnAuthorizedException.class) + .hasMessage("비밀번호가 일치하지 않습니다"); + } + + @Test + void 로그인_시_비밀번호가_일치하면_성공() { + // given + Member member = new Member("u", "p", "n", "p"); + + // when & then + assertDoesNotThrow(() -> { + member.login("p"); + }); + } +} diff --git a/src/test/java/backend/likelion/todos/todo/TodoAcceptanceTest.java b/src/test/java/backend/likelion/todos/todo/TodoAcceptanceTest.java new file mode 100644 index 0000000..a861fef --- /dev/null +++ b/src/test/java/backend/likelion/todos/todo/TodoAcceptanceTest.java @@ -0,0 +1,136 @@ +package backend.likelion.todos.todo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("투두 인수테스트") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class TodoAcceptanceTest { + + @Nested + class 투두_생성_시 { + + @Test + void 목표가_내_목표가_아니면_예외() { + // given + + // when + + // then + } + + @Test + void 투두를_생성한다() { + // given + + // when + + // then + } + } + + @Nested + class 투두_체크_시 { + + @Test + void 내_투두가_아니면_예외() { + // given + + // when + + // then + } + + @Test + void 투두를_체크한다() { + // given + + // when + + // then + } + } + + @Nested + class 투두_체크_취소_시 { + + @Test + void 내_투두가_아니면_예외() { + // given + + // when + + // then + } + + @Test + void 투두_체크를_취소한다() { + // given + + // when + + // then + } + } + + @Nested + class 투두_수정_시 { + + @Test + void 내_투두가_아니면_예외() { + // given + + // when + + // then + } + + @Test + void 투두를_수정한다() { + // given + + // when + + // then + } + } + + @Nested + class 투두_삭제_시 { + + @Test + void 내_투두가_아니면_예외() { + // given + + // when + + // then + } + + @Test + void 투두를_삭제한다() { + // given + + // when + + // then + } + } + + @Nested + class 나의_특정_년도와_달의_투두_조회_시 { + + @Test + void 조회한다() { + // given + + // when + + // then + } + } +} diff --git a/src/test/java/backend/likelion/todos/todo/TodoRepositoryTest.java b/src/test/java/backend/likelion/todos/todo/TodoRepositoryTest.java new file mode 100644 index 0000000..87fd675 --- /dev/null +++ b/src/test/java/backend/likelion/todos/todo/TodoRepositoryTest.java @@ -0,0 +1,51 @@ +package backend.likelion.todos.todo; + +import static org.assertj.core.api.Assertions.assertThat; + +import backend.likelion.todos.goal.Goal; +import backend.likelion.todos.member.Member; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("투두 저장소 (TodoRepository) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class TodoRepositoryTest { + + private final TodoRepository todoRepository = new TodoRepository(); + private final Member member = new Member("u", "p", "n", "p"); + private final Member other = new Member("o", "p", "n", "p"); + private final Goal goal1 = new Goal("", "", member); + private final Goal goal2 = new Goal("", "", member); + private final Goal goal3 = new Goal("", "", other); + + @Test + void 특정_년도와_달에_포함된_특정_회원의_TODO_를_찾아_1일부터_오름차순으로_조회한다() { + // given + member.setId(1L); + other.setId(2L); + todoRepository.save(new Todo("1", LocalDate.of(2024, 10, 1), goal3)); + + todoRepository.save(new Todo("3", LocalDate.of(2024, 10, 3), goal2)); + todoRepository.save(new Todo("4", LocalDate.of(2024, 10, 4), goal1)); + todoRepository.save(new Todo("2", LocalDate.of(2024, 10, 1), goal1)); + todoRepository.save(new Todo("5", LocalDate.of(2024, 10, 21), goal2)); + + todoRepository.save(new Todo("6", LocalDate.of(2024, 11, 21), goal1)); + todoRepository.save(new Todo("7", LocalDate.of(2043, 10, 21), goal1)); + + // when + List result = todoRepository.findAllByMemberIdAndDate(member.getId(), YearMonth.of(2024, 10)); + + // then + assertThat(result) + .hasSize(4) + .extracting(Todo::getContent) + .containsExactly("2", "3", "4", "5"); + } +} diff --git a/src/test/java/backend/likelion/todos/todo/TodoServiceTest.java b/src/test/java/backend/likelion/todos/todo/TodoServiceTest.java new file mode 100644 index 0000000..293fdb9 --- /dev/null +++ b/src/test/java/backend/likelion/todos/todo/TodoServiceTest.java @@ -0,0 +1,313 @@ +package backend.likelion.todos.todo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.common.NotFoundException; +import backend.likelion.todos.goal.Goal; +import backend.likelion.todos.goal.GoalRepository; +import backend.likelion.todos.member.Member; +import backend.likelion.todos.member.MemberRepository; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@DisplayName("투두 서비스 (TodoService) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class TodoServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private TodoRepository todoRepository; + + @Autowired + private TodoService todoService; + + private Member member; + private Member other; + private Goal goal; + + @BeforeEach + void setUp() { + todoRepository.clear(); + goalRepository.clear(); + memberRepository.clear(); + member = memberRepository.save(new Member("1", "1", "1", "1")); + other = memberRepository.save(new Member("2", "2", "2", "2")); + goal = goalRepository.save(new Goal("g", "c", member)); + } + + @Nested + class 투두_생성_시 { + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.save(1L, 100L, "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 목표_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.save(100L, member.getId(), "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(NotFoundException.class) + .hasMessage("목표 정보가 없습니다."); + } + + @Test + void 나의_목표가_아니면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.save(goal.getId(), other.getId(), "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 목표에 대한 권한이 없습니다."); + } + + @Test + void 투두를_생성한다() { + // when + Long todoId = todoService.save(goal.getId(), member.getId(), "", LocalDate.of(2000, 10, 4)); + + // then + assertThat(todoRepository.findById(todoId)).isPresent(); + } + } + + @Nested + class 투두_수정_시 { + + private Long todoId; + + @BeforeEach + void setUp() { + todoId = todoService.save(goal.getId(), member.getId(), "", LocalDate.of(2000, 10, 4)); + } + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.update(todoId, 100L, "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 투두_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.update(100L, member.getId(), "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(NotFoundException.class) + .hasMessage("투두 정보가 없습니다."); + } + + @Test + void 나의_투두가_아니면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.update(todoId, other.getId(), "", LocalDate.of(2000, 10, 4)); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 투두에 대한 권한이 없습니다."); + } + + @Test + void 투두의_내용과_날짜를_수정한다() { + // when + todoService.update(todoId, member.getId(), "u", LocalDate.of(1000, 12, 4)); + + // then + Todo todo = todoRepository.findById(todoId).get(); + assertThat(todo.getContent()).isEqualTo("u"); + assertThat(todo.getDate()).isEqualTo(LocalDate.of(1000, 12, 4)); + } + } + + @Nested + class 투두_체크_시 { + + private Long todoId; + + @BeforeEach + void setUp() { + todoId = todoService.save(goal.getId(), member.getId(), "", LocalDate.of(2000, 10, 4)); + } + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.check(todoId, 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 투두_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.check(100L, member.getId()); + }).isInstanceOf(NotFoundException.class) + .hasMessage("투두 정보가 없습니다."); + } + + @Test + void 나의_투두가_아니면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.check(todoId, other.getId()); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 투두에 대한 권한이 없습니다."); + } + + @Test + void 투두를_체크한다() { + // when + todoService.check(todoId, member.getId()); + + // then + assertThat(todoRepository.findById(todoId).get().isCompleted()).isTrue(); + } + } + + @Nested + class 투두_체크_취소_시 { + + private Long todoId; + + @BeforeEach + void setUp() { + todoId = todoService.save(goal.getId(), member.getId(), "", LocalDate.of(2000, 10, 4)); + } + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.uncheck(todoId, 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 투두_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.uncheck(100L, member.getId()); + }).isInstanceOf(NotFoundException.class) + .hasMessage("투두 정보가 없습니다."); + } + + @Test + void 나의_투두가_아니면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.uncheck(todoId, other.getId()); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 투두에 대한 권한이 없습니다."); + } + + @Test + void 투두를_체크한다() { + // given + todoService.check(todoId, member.getId()); + + // when + todoService.uncheck(todoId, member.getId()); + + // then + assertThat(todoRepository.findById(todoId).get().isCompleted()).isFalse(); + } + } + + @Nested + class 투두_삭제_시 { + + private Long todoId; + + @BeforeEach + void setUp() { + todoId = todoService.save(goal.getId(), member.getId(), "", LocalDate.of(2000, 10, 4)); + } + + @Test + void 회원_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.delete(todoId, 100L); + }).isInstanceOf(NotFoundException.class) + .hasMessage("회원 정보가 없습니다."); + } + + @Test + void 투두_정보가_없으면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.delete(100L, member.getId()); + }).isInstanceOf(NotFoundException.class) + .hasMessage("투두 정보가 없습니다."); + } + + @Test + void 나의_투두가_아니면_예외() { + // when & then + assertThatThrownBy(() -> { + todoService.delete(todoId, other.getId()); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 투두에 대한 권한이 없습니다."); + } + + @Test + void 투두를_제거한다() { + // when + todoService.delete(todoId, member.getId()); + + // then + assertThat(todoRepository.findById(todoId)).isEmpty(); + } + } + + @Nested + class 특정_년도와_달에_포함된_특정_회원의_TODO_조회_시 { + + @Test + void 특정_년도와_달에_포함된_특정_회원의_TODO_를_찾아_1일부터_오름차순으로_조회한다() { + // given + todoRepository.save(new Todo("3", LocalDate.of(2024, 10, 3), goal)); + todoRepository.save(new Todo("4", LocalDate.of(2024, 10, 4), goal)); + todoRepository.save(new Todo("2", LocalDate.of(2024, 10, 1), goal)); + todoRepository.save(new Todo("5", LocalDate.of(2024, 10, 21), goal)); + + todoRepository.save(new Todo("6", LocalDate.of(2024, 11, 21), goal)); + todoRepository.save(new Todo("7", LocalDate.of(2043, 10, 21), goal)); + + // when + List result = todoRepository.findAllByMemberIdAndDate(member.getId(), YearMonth.of(2024, 10)); + + // then + assertThat(result) + .hasSize(4) + .extracting(Todo::getContent) + .containsExactly("2", "3", "4", "5"); + } + } +} diff --git a/src/test/java/backend/likelion/todos/todo/TodoTest.java b/src/test/java/backend/likelion/todos/todo/TodoTest.java new file mode 100644 index 0000000..5ec1936 --- /dev/null +++ b/src/test/java/backend/likelion/todos/todo/TodoTest.java @@ -0,0 +1,90 @@ +package backend.likelion.todos.todo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import backend.likelion.todos.common.ForbiddenException; +import backend.likelion.todos.goal.Goal; +import backend.likelion.todos.member.Member; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("투두 (Todo) 은(는)") +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class TodoTest { + + private final Member member = new Member("u", "p", "n", "p"); + private final Member other = new Member("o", "p", "n", "p"); + private final Goal goal = new Goal("", "", member); + + @Nested + class 회원_검증_시 { + + @Test + void 주인이_아니면_예외() { + // given + Todo todo = new Todo("content", LocalDate.now(), goal); + + // when & then + assertThatThrownBy(() -> { + todo.validateMember(other); + }).isInstanceOf(ForbiddenException.class) + .hasMessage("해당 투두에 대한 권한이 없습니다."); + } + + @Test + void 주인이라면_통과() { + // given + Todo todo = new Todo("content", LocalDate.now(), goal); + + // when & then + assertDoesNotThrow(() -> { + todo.validateMember(member); + }); + } + } + + @Test + void 내용과_날짜를_수정할_수_있다() { + // given + Todo todo = new Todo("content", LocalDate.now(), goal); + + // when + todo.update("u", LocalDate.now().plusDays(2)); + + // then + assertThat(todo.getContent()).isEqualTo("u"); + assertThat(todo.getDate()).isEqualTo(LocalDate.now().plusDays(2)); + } + + @Test + void 체크할_수_있다() { + // given + Todo todo = new Todo("content", LocalDate.now(), goal); + + // when + todo.check(); + + // then + assertThat(todo.isCompleted()).isTrue(); + } + + @Test + void 체크를_취소할_수_있다() { + // given + Todo todo = new Todo("content", LocalDate.now(), goal); + todo.check(); + + // when + todo.uncheck(); + + // then + assertThat(todo.isCompleted()).isFalse(); + } +}